feat(auth): replace Google login with credentials
This commit is contained in:
+1
-3
@@ -6,11 +6,9 @@ BUZZ_DEMO_MODE=false
|
|||||||
DATABASE_URL=postgresql://buzz_sheet:[email protected]:5432/buzz_sheet?sslmode=require
|
DATABASE_URL=postgresql://buzz_sheet:[email protected]:5432/buzz_sheet?sslmode=require
|
||||||
DATABASE_POOL_SIZE=10
|
DATABASE_POOL_SIZE=10
|
||||||
|
|
||||||
# Better Auth + Google OAuth
|
# Better Auth email/password administrator login
|
||||||
BETTER_AUTH_URL=http://localhost:3000
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
BETTER_AUTH_SECRET=replace-with-at-least-32-random-bytes
|
BETTER_AUTH_SECRET=replace-with-at-least-32-random-bytes
|
||||||
GOOGLE_CLIENT_ID=replace-with-google-client-id
|
|
||||||
GOOGLE_CLIENT_SECRET=replace-with-google-client-secret
|
|
||||||
ADMIN_EMAIL=[email protected]
|
ADMIN_EMAIL=[email protected]
|
||||||
|
|
||||||
# Redis remote cache, event transport, and outbox worker
|
# Redis remote cache, event transport, and outbox worker
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ The admin uses a visual Notion-style block editor with forms, cards, drag-and-dr
|
|||||||
- Cache validated public page snapshots by their page and data-source version vector. Use an external Redis-backed Next.js remote cache and tag handler so both application replicas share cache state and invalidations. Keep admin routes dynamic.
|
- Cache validated public page snapshots by their page and data-source version vector. Use an external Redis-backed Next.js remote cache and tag handler so both application replicas share cache state and invalidations. Keep admin routes dynamic.
|
||||||
- Write cache invalidation and SSE work to a transactional outbox with the content mutation. A retrying worker invalidates affected page, directory, and data-source tags only after the database commit, then publishes typed Redis events.
|
- Write cache invalidation and SSE work to a transactional outbox with the content mutation. A retrying worker invalidates affected page, directory, and data-source tags only after the database commit, then publishes typed Redis events.
|
||||||
- Provide Redis-backed, invalidation-only SSE streams for public pages, the character directory, and authenticated admin sessions. Events carry only opaque IDs and versions; clients refetch authoritative state on connection, reconnection, or notification. Send 90-second heartbeats, close streams after 30 minutes so clients reconnect, and disable Traefik response buffering. SSE is never a source of correctness and never carries page content.
|
- Provide Redis-backed, invalidation-only SSE streams for public pages, the character directory, and authenticated admin sessions. Events carry only opaque IDs and versions; clients refetch authoritative state on connection, reconnection, or notification. Send 90-second heartbeats, close streams after 30 minutes so clients reconnect, and disable Traefik response buffering. SSE is never a source of correctness and never carries page content.
|
||||||
- Authenticate through Google using Better Auth. Permit admin access only when the verified Google email equals `ADMIN_EMAIL`; repeat authorization checks in every mutation, media, and administrative endpoint.
|
- Authenticate with Better Auth email and password. Permit registration and admin access only when the credential account email exactly equals `ADMIN_EMAIL`; use a temporary `/register` bootstrap page and repeat authorization checks in every mutation, media, and administrative endpoint.
|
||||||
- Upload PNG, JPEG, WebP, and GIF assets up to 20 MB through short-lived presigned requests. Serve them through same-origin `/media/[id]` responses. Allow public access only while an asset is referenced by a currently visible snapshot; otherwise require admin authentication. Retain objects while referenced by either current content or retained revisions.
|
- Upload PNG, JPEG, WebP, and GIF assets up to 20 MB through short-lived presigned requests. Serve them through same-origin `/media/[id]` responses. Allow public access only while an asset is referenced by a currently visible snapshot; otherwise require admin authentication. Retain objects while referenced by either current content or retained revisions.
|
||||||
- Applying a template clones independent pages, blocks, and data sources. Later template edits affect only future applications.
|
- Applying a template clones independent pages, blocks, and data sources. Later template edits affect only future applications.
|
||||||
- Do not provide custom HTML, JavaScript, TypeScript, React, CSS, code blocks that execute, external scripts, arbitrary npm packages, or network-capable extensions.
|
- Do not provide custom HTML, JavaScript, TypeScript, React, CSS, code blocks that execute, external scripts, arbitrary npm packages, or network-capable extensions.
|
||||||
@@ -135,8 +135,8 @@ flowchart LR
|
|||||||
- Implement internally in four gates:
|
- Implement internally in four gates:
|
||||||
1. Database model, immutable data-source versions, revisions, transactional outbox, formula engine, and workbook-derived fixtures.
|
1. Database model, immutable data-source versions, revisions, transactional outbox, formula engine, and workbook-derived fixtures.
|
||||||
2. Public renderer, templates, and responsive shadcn admin editor.
|
2. Public renderer, templates, and responsive shadcn admin editor.
|
||||||
3. Google authentication, reference-aware S3 media handling, Redis-backed caching and SSE, and conflict recovery.
|
3. Better Auth email/password authentication, reference-aware S3 media handling, Redis-backed caching and SSE, and conflict recovery.
|
||||||
4. Production builds, browser tests, Docker, migrations, Kubernetes, and CI.
|
4. Production builds, HTTP and integration tests, Docker, migrations, Kubernetes, and CI.
|
||||||
- Release to production once all four gates pass.
|
- Release to production once all four gates pass.
|
||||||
- During the public/editor gate, initialize shadcn with the `base-nova` preset before adding components. Add only the official components required by the implemented surface, and review generated component source and Base UI composition after each addition.
|
- During the public/editor gate, initialize shadcn with the `base-nova` preset before adding components. Add only the official components required by the implemented surface, and review generated component source and Base UI composition after each addition.
|
||||||
- Implement and commit each completed feature separately. Do not accumulate the project into one large commit.
|
- Implement and commit each completed feature separately. Do not accumulate the project into one large commit.
|
||||||
@@ -167,7 +167,7 @@ flowchart LR
|
|||||||
- Verify those 379 formulas with deterministic decimal results, then separately test precedence, subtraction, multiplication, percentages, unary signs, blank and missing references, cycles, dependency-propagated failures, rounding, and division by zero.
|
- Verify those 379 formulas with deterministic decimal results, then separately test precedence, subtraction, multiplication, percentages, unary signs, blank and missing references, cycles, dependency-propagated failures, rounding, and division by zero.
|
||||||
- Test block validation, schema migration, unknown-block fallback, templates, custom slugs, redirects, public notes, visibility, ordering, and revision restore.
|
- Test block validation, schema migration, unknown-block fallback, templates, custom slugs, redirects, public notes, visibility, ordering, and revision restore.
|
||||||
- Test autosave debounce, serialization, transient retries, concurrent conflicts, checkpoint coalescing, named revisions, 30-day expiry, global data-source dependency updates, historical version pinning, “use latest,” and media retention.
|
- Test autosave debounce, serialization, transient retries, concurrent conflicts, checkpoint coalescing, named revisions, 30-day expiry, global data-source dependency updates, historical version pinning, “use latest,” and media retention.
|
||||||
- Test Google admin restrictions and authorization on every write/media endpoint.
|
- Test email/password registration restrictions and authorization on every write/media endpoint.
|
||||||
- Test upload limits, file validation, failed upload recovery, and same-origin media delivery.
|
- Test upload limits, file validation, failed upload recovery, and same-origin media delivery.
|
||||||
- Test cached public snapshots and immediate dependency-aware invalidation following accepted autosaves.
|
- Test cached public snapshots and immediate dependency-aware invalidation following accepted autosaves.
|
||||||
- Test SSE authorization, public-event privacy, heartbeat, reconnection and authoritative refetch, duplicate or missed notifications, and directory/page/admin topics.
|
- Test SSE authorization, public-event privacy, heartbeat, reconnection and authoritative refetch, duplicate or missed notifications, and directory/page/admin topics.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Production target: `https://sheet.sudloh.com`
|
|||||||
- Restricted decimal formula engine with named references, dependency ordering,
|
- Restricted decimal formula engine with named references, dependency ordering,
|
||||||
cycle detection, typed failures, and presentation-only rounding.
|
cycle detection, typed failures, and presentation-only rounding.
|
||||||
- Read-only comparison charts with visible values and accessible table fallbacks.
|
- Read-only comparison charts with visible values and accessible table fallbacks.
|
||||||
- Google authentication restricted to one verified `ADMIN_EMAIL`.
|
- Better Auth email/password login restricted to the configured `ADMIN_EMAIL`.
|
||||||
- Private S3-compatible media uploads with reference-aware same-origin delivery.
|
- Private S3-compatible media uploads with reference-aware same-origin delivery.
|
||||||
- PostgreSQL transactions, immutable data-source versions, revisions, and a
|
- PostgreSQL transactions, immutable data-source versions, revisions, and a
|
||||||
retryable outbox.
|
retryable outbox.
|
||||||
@@ -37,7 +37,8 @@ formula fixtures. Their guide text and media are not imported or published.
|
|||||||
| `/` | Searchable public character directory |
|
| `/` | Searchable public character directory |
|
||||||
| `/[character]` | Redirect to the first visible page |
|
| `/[character]` | Redirect to the first visible page |
|
||||||
| `/[character]/[page]` | Render a public guide page |
|
| `/[character]/[page]` | Render a public guide page |
|
||||||
| `/admin/login` | Google administrator sign-in |
|
| `/admin/login` | Administrator email/password sign-in |
|
||||||
|
| `/register` | Temporary administrator account bootstrap |
|
||||||
| `/admin` | Character and page overview |
|
| `/admin` | Character and page overview |
|
||||||
| `/admin/[character]/[page]` | Visual page editor |
|
| `/admin/[character]/[page]` | Visual page editor |
|
||||||
| `/admin/templates` | Apply reusable guide templates |
|
| `/admin/templates` | Apply reusable guide templates |
|
||||||
@@ -56,7 +57,6 @@ Requirements:
|
|||||||
- PostgreSQL
|
- PostgreSQL
|
||||||
- Redis
|
- Redis
|
||||||
- Private S3-compatible object storage
|
- Private S3-compatible object storage
|
||||||
- Google OAuth credentials
|
|
||||||
|
|
||||||
Install dependencies and create your local environment file:
|
Install dependencies and create your local environment file:
|
||||||
|
|
||||||
@@ -66,16 +66,11 @@ cp .env.example .env
|
|||||||
```
|
```
|
||||||
|
|
||||||
`.env.example` contains placeholders only. Configure `.env` yourself; it is
|
`.env.example` contains placeholders only. Configure `.env` yourself; it is
|
||||||
ignored by Git and must never be committed. For Google OAuth, register this
|
ignored by Git and must never be committed. `BETTER_AUTH_URL` must exactly
|
||||||
authorized redirect URI:
|
match the application origin. Visit `/register` once to create the credential
|
||||||
|
account using the exact `ADMIN_EMAIL`, then use `/admin/login` for later access.
|
||||||
```text
|
The registration endpoint rejects every other email; remove the temporary page
|
||||||
http://localhost:3000/api/auth/callback/google
|
after the administrator account has been created.
|
||||||
```
|
|
||||||
|
|
||||||
Use the production origin in place of `http://localhost:3000` for the deployed
|
|
||||||
OAuth client. `BETTER_AUTH_URL` must exactly match that origin, and access is
|
|
||||||
granted only when Google reports a verified email equal to `ADMIN_EMAIL`.
|
|
||||||
|
|
||||||
Prepare the database and start the application:
|
Prepare the database and start the application:
|
||||||
|
|
||||||
@@ -90,7 +85,8 @@ reference workbook content.
|
|||||||
|
|
||||||
### Fixture-only demo
|
### Fixture-only demo
|
||||||
|
|
||||||
The UI can be inspected without PostgreSQL, Redis, S3, or Google by running:
|
The UI can be inspected without PostgreSQL, Redis, S3, or authentication
|
||||||
|
credentials by running:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
BUZZ_DEMO_MODE=true bun run dev
|
BUZZ_DEMO_MODE=true bun run dev
|
||||||
@@ -210,8 +206,6 @@ Before the first rollout, a cluster administrator must provision a
|
|||||||
```text
|
```text
|
||||||
DATABASE_URL
|
DATABASE_URL
|
||||||
BETTER_AUTH_SECRET
|
BETTER_AUTH_SECRET
|
||||||
GOOGLE_CLIENT_ID
|
|
||||||
GOOGLE_CLIENT_SECRET
|
|
||||||
ADMIN_EMAIL
|
ADMIN_EMAIL
|
||||||
REDIS_URL
|
REDIS_URL
|
||||||
S3_ENDPOINT
|
S3_ENDPOINT
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { connection } from "next/server";
|
||||||
|
|
||||||
|
import { RegisterCard } from "@/components/admin/register-card";
|
||||||
|
import { SiteHeader } from "@/components/public/site-header";
|
||||||
|
import { getAdminSession } from "@/lib/auth/server";
|
||||||
|
|
||||||
|
export default async function RegisterPage() {
|
||||||
|
await connection();
|
||||||
|
if (await getAdminSession()) redirect("/admin");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-svh">
|
||||||
|
<SiteHeader />
|
||||||
|
<main className="grid min-h-[calc(100svh-4rem)] place-items-center px-4 py-12">
|
||||||
|
<RegisterCard />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,33 +1,62 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { KeyRoundIcon, LogInIcon } from "lucide-react";
|
import { CircleAlertIcon, LogInIcon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { type FormEvent, useState } from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertDescription,
|
||||||
|
AlertTitle,
|
||||||
|
} from "@/components/ui/alert";
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} 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 { Spinner } from "@/components/ui/spinner";
|
||||||
import { authClient } from "@/lib/auth/client";
|
import { authClient } from "@/lib/auth/client";
|
||||||
|
|
||||||
export function LoginCard() {
|
export function LoginCard() {
|
||||||
|
const router = useRouter();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
async function signIn() {
|
async function signIn(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const result = await authClient.signIn.social({
|
|
||||||
provider: "google",
|
const formData = new FormData(event.currentTarget);
|
||||||
callbackURL: "/admin",
|
const email = String(formData.get("email") ?? "").trim();
|
||||||
errorCallbackURL: "/admin/login?error=oauth",
|
const password = String(formData.get("password") ?? "");
|
||||||
});
|
|
||||||
if (result.error) {
|
try {
|
||||||
setError("เข้าสู่ระบบไม่สำเร็จ โปรดลองอีกครั้ง");
|
const result = await authClient.signIn.email({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
rememberMe: true,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
setError(
|
||||||
|
result.error.code === "INVALID_EMAIL_OR_PASSWORD"
|
||||||
|
? "อีเมลหรือรหัสผ่านไม่ถูกต้อง"
|
||||||
|
: "เข้าสู่ระบบไม่สำเร็จ โปรดลองอีกครั้ง",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push("/admin");
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
setError("เชื่อมต่อระบบเข้าสู่ระบบไม่ได้ โปรดลองอีกครั้ง");
|
||||||
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,21 +64,64 @@ export function LoginCard() {
|
|||||||
return (
|
return (
|
||||||
<Card className="w-full max-w-sm">
|
<Card className="w-full max-w-sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="mb-3 grid size-11 place-items-center rounded-xl bg-primary text-primary-foreground">
|
|
||||||
<LogInIcon aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-xl">เข้าสู่ระบบผู้ดูแล</CardTitle>
|
<CardTitle className="text-xl">เข้าสู่ระบบผู้ดูแล</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
ใช้บัญชี Google ที่ได้รับอนุญาตให้จัดการคู่มือ
|
ใช้อีเมลและรหัสผ่านของบัญชีผู้ดูแล Better Auth
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent>
|
||||||
<Button className="w-full" size="lg" onClick={signIn} disabled={loading}>
|
<form onSubmit={signIn}>
|
||||||
{loading ? <Spinner /> : <KeyRoundIcon aria-hidden="true" />}
|
<FieldGroup>
|
||||||
{loading ? "กำลังเชื่อมต่อ…" : "ดำเนินการต่อด้วย Google"}
|
<Field>
|
||||||
</Button>
|
<FieldLabel htmlFor="login-email">อีเมล</FieldLabel>
|
||||||
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
|
<Input
|
||||||
|
id="login-email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="username"
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
|
disabled={loading}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="login-password">รหัสผ่าน</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="login-password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
disabled={loading}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<CircleAlertIcon aria-hidden="true" />
|
||||||
|
<AlertTitle>เข้าสู่ระบบไม่สำเร็จ</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
<Button className="w-full" size="lg" type="submit" disabled={loading}>
|
||||||
|
{loading ? (
|
||||||
|
<Spinner data-icon="inline-start" />
|
||||||
|
) : (
|
||||||
|
<LogInIcon data-icon="inline-start" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
{loading ? "กำลังเข้าสู่ระบบ…" : "เข้าสู่ระบบ"}
|
||||||
|
</Button>
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
<CardFooter className="justify-center">
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className={buttonVariants({ variant: "link", size: "sm" })}
|
||||||
|
>
|
||||||
|
สร้างบัญชีผู้ดูแลชั่วคราว
|
||||||
|
</Link>
|
||||||
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CircleAlertIcon, UserPlusIcon } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { type FormEvent, 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,
|
||||||
|
FieldDescription,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLabel,
|
||||||
|
} from "@/components/ui/field";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { authClient } from "@/lib/auth/client";
|
||||||
|
|
||||||
|
function registrationError(code: string | undefined): string {
|
||||||
|
switch (code) {
|
||||||
|
case "ADMIN_REGISTRATION_FORBIDDEN":
|
||||||
|
return "อีเมลนี้ไม่ได้รับอนุญาตให้สร้างบัญชีผู้ดูแล";
|
||||||
|
case "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL":
|
||||||
|
return "บัญชีนี้มีอยู่แล้ว โปรดกลับไปเข้าสู่ระบบ";
|
||||||
|
case "PASSWORD_TOO_SHORT":
|
||||||
|
return "รหัสผ่านต้องมีอย่างน้อย 8 ตัวอักษร";
|
||||||
|
case "PASSWORD_TOO_LONG":
|
||||||
|
return "รหัสผ่านต้องไม่เกิน 128 ตัวอักษร";
|
||||||
|
default:
|
||||||
|
return "สร้างบัญชีไม่สำเร็จ โปรดตรวจสอบข้อมูลแล้วลองอีกครั้ง";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RegisterCard() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function register(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
const name = String(formData.get("name") ?? "").trim();
|
||||||
|
const email = String(formData.get("email") ?? "").trim();
|
||||||
|
const password = String(formData.get("password") ?? "");
|
||||||
|
const confirmation = String(formData.get("passwordConfirmation") ?? "");
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
setError("กรุณากรอกชื่อผู้ดูแล");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password !== confirmation) {
|
||||||
|
setError("รหัสผ่านทั้งสองช่องไม่ตรงกัน");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await authClient.signUp.email({
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
setError(registrationError(result.error.code));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push("/admin");
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
setError("เชื่อมต่อระบบลงทะเบียนไม่ได้ โปรดลองอีกครั้ง");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl">สร้างบัญชีผู้ดูแล</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
ลงทะเบียนด้วยอีเมลและรหัสผ่านสำหรับ Better Auth
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={register}>
|
||||||
|
<FieldGroup>
|
||||||
|
<Alert>
|
||||||
|
<CircleAlertIcon aria-hidden="true" />
|
||||||
|
<AlertTitle>หน้าลงทะเบียนชั่วคราว</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
ระบบรับเฉพาะอีเมลที่ตรงกับ ADMIN_EMAIL เท่านั้น
|
||||||
|
ควรนำหน้านี้ออกหลังสร้างบัญชีสำเร็จ
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="register-name">ชื่อผู้ดูแล</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="register-name"
|
||||||
|
name="name"
|
||||||
|
autoComplete="name"
|
||||||
|
disabled={loading}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="register-email">อีเมล</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="register-email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="username"
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
|
disabled={loading}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="register-password">รหัสผ่าน</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="register-password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={8}
|
||||||
|
maxLength={128}
|
||||||
|
disabled={loading}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<FieldDescription>อย่างน้อย 8 ตัวอักษร</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="register-password-confirmation">
|
||||||
|
ยืนยันรหัสผ่าน
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="register-password-confirmation"
|
||||||
|
name="passwordConfirmation"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={8}
|
||||||
|
maxLength={128}
|
||||||
|
disabled={loading}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<CircleAlertIcon aria-hidden="true" />
|
||||||
|
<AlertTitle>สร้างบัญชีไม่สำเร็จ</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
<Button className="w-full" size="lg" type="submit" disabled={loading}>
|
||||||
|
{loading ? (
|
||||||
|
<Spinner data-icon="inline-start" />
|
||||||
|
) : (
|
||||||
|
<UserPlusIcon data-icon="inline-start" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
{loading ? "กำลังสร้างบัญชี…" : "สร้างบัญชีผู้ดูแล"}
|
||||||
|
</Button>
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="justify-center">
|
||||||
|
<Link
|
||||||
|
href="/admin/login"
|
||||||
|
className={buttonVariants({ variant: "link", size: "sm" })}
|
||||||
|
>
|
||||||
|
กลับไปเข้าสู่ระบบ
|
||||||
|
</Link>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
+2
-2
@@ -24,8 +24,8 @@ const timestamps = {
|
|||||||
.notNull(),
|
.notNull(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Better Auth core tables. Application authorization still compares the
|
// Better Auth core tables. Credential registration is restricted to
|
||||||
// verified session email with ADMIN_EMAIL at every protected boundary.
|
// ADMIN_EMAIL, which is rechecked at every protected application boundary.
|
||||||
export const users = pgTable(
|
export const users = pgTable(
|
||||||
"user",
|
"user",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -69,4 +69,4 @@ and `redis` set to `"ok"`.
|
|||||||
it requires building and deploying a new image; restarting the existing image
|
it requires building and deploying a new image; restarting the existing image
|
||||||
is not sufficient.
|
is not sufficient.
|
||||||
- If `BETTER_AUTH_URL` or the public hostname changes, update the Kubernetes
|
- If `BETTER_AUTH_URL` or the public hostname changes, update the Kubernetes
|
||||||
ingress, Cloudflare/DNS, and the Google OAuth callback URL as well.
|
ingress and Cloudflare/DNS configuration as well.
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { isAuthorizedAdmin } from "./authorization";
|
import {
|
||||||
|
isAllowedAdminRegistration,
|
||||||
|
isAuthorizedAdmin,
|
||||||
|
isConfiguredAdminEmail,
|
||||||
|
} from "./authorization";
|
||||||
|
|
||||||
const verified = {
|
const verified = {
|
||||||
id: "admin",
|
id: "admin",
|
||||||
@@ -9,6 +13,36 @@ const verified = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("admin authorization", () => {
|
describe("admin authorization", () => {
|
||||||
|
it("recognizes only the exact configured administrator email", () => {
|
||||||
|
expect(isConfiguredAdminEmail(verified.email, "[email protected]")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(isConfiguredAdminEmail(verified.email, "[email protected]")).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(isConfiguredAdminEmail(verified.email, undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows only credential registration for the configured administrator", () => {
|
||||||
|
expect(
|
||||||
|
isAllowedAdminRegistration(
|
||||||
|
verified.email,
|
||||||
|
"email-password",
|
||||||
|
verified.email,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isAllowedAdminRegistration(
|
||||||
|
"[email protected]",
|
||||||
|
"email-password",
|
||||||
|
verified.email,
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
isAllowedAdminRegistration(verified.email, "oauth", verified.email),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("requires an exact verified email match", () => {
|
it("requires an exact verified email match", () => {
|
||||||
expect(isAuthorizedAdmin(verified, "[email protected]")).toBe(true);
|
expect(isAuthorizedAdmin(verified, "[email protected]")).toBe(true);
|
||||||
expect(isAuthorizedAdmin(verified, "[email protected]")).toBe(false);
|
expect(isAuthorizedAdmin(verified, "[email protected]")).toBe(false);
|
||||||
|
|||||||
@@ -6,6 +6,24 @@ export interface SessionUserLike {
|
|||||||
image?: string | null;
|
image?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isConfiguredAdminEmail(
|
||||||
|
email: string | null | undefined,
|
||||||
|
adminEmail: string | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
return Boolean(email && adminEmail && email === adminEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAllowedAdminRegistration(
|
||||||
|
email: string | null | undefined,
|
||||||
|
authMethod: string,
|
||||||
|
adminEmail: string | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
authMethod === "email-password" &&
|
||||||
|
isConfiguredAdminEmail(email, adminEmail)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function isAuthorizedAdmin(
|
export function isAuthorizedAdmin(
|
||||||
user: SessionUserLike | null | undefined,
|
user: SessionUserLike | null | undefined,
|
||||||
adminEmail: string | null | undefined,
|
adminEmail: string | null | undefined,
|
||||||
@@ -13,7 +31,6 @@ export function isAuthorizedAdmin(
|
|||||||
return Boolean(
|
return Boolean(
|
||||||
user &&
|
user &&
|
||||||
user.emailVerified === true &&
|
user.emailVerified === true &&
|
||||||
adminEmail &&
|
isConfiguredAdminEmail(user.email, adminEmail),
|
||||||
user.email === adminEmail,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-5
@@ -13,7 +13,12 @@ import {
|
|||||||
verifications,
|
verifications,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
|
|
||||||
import { isAuthorizedAdmin, type SessionUserLike } from "./authorization";
|
import {
|
||||||
|
isAllowedAdminRegistration,
|
||||||
|
isAuthorizedAdmin,
|
||||||
|
isConfiguredAdminEmail,
|
||||||
|
type SessionUserLike,
|
||||||
|
} from "./authorization";
|
||||||
|
|
||||||
function required(name: string): string {
|
function required(name: string): string {
|
||||||
const value = process.env[name];
|
const value = process.env[name];
|
||||||
@@ -22,6 +27,8 @@ function required(name: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createAuth() {
|
function createAuth() {
|
||||||
|
const adminEmail = required("ADMIN_EMAIL");
|
||||||
|
|
||||||
return betterAuth({
|
return betterAuth({
|
||||||
appName: "Buzz Sheet",
|
appName: "Buzz Sheet",
|
||||||
database: drizzleAdapter(getDb(), {
|
database: drizzleAdapter(getDb(), {
|
||||||
@@ -36,10 +43,36 @@ function createAuth() {
|
|||||||
}),
|
}),
|
||||||
baseURL: required("BETTER_AUTH_URL"),
|
baseURL: required("BETTER_AUTH_URL"),
|
||||||
secret: required("BETTER_AUTH_SECRET"),
|
secret: required("BETTER_AUTH_SECRET"),
|
||||||
socialProviders: {
|
emailAndPassword: {
|
||||||
google: {
|
enabled: true,
|
||||||
clientId: required("GOOGLE_CLIENT_ID"),
|
minPasswordLength: 8,
|
||||||
clientSecret: required("GOOGLE_CLIENT_SECRET"),
|
maxPasswordLength: 128,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
validateUserInfo: async ({ user, source }) => {
|
||||||
|
if (
|
||||||
|
!isAllowedAdminRegistration(
|
||||||
|
user.email,
|
||||||
|
source.method,
|
||||||
|
adminEmail,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
error: "ADMIN_REGISTRATION_FORBIDDEN",
|
||||||
|
errorDescription:
|
||||||
|
"Registration is limited to the configured administrator.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
databaseHooks: {
|
||||||
|
user: {
|
||||||
|
create: {
|
||||||
|
before: async (user) => {
|
||||||
|
if (!isConfiguredAdminEmail(user.email, adminEmail)) return false;
|
||||||
|
return { data: { emailVerified: true } };
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [nextCookies()],
|
plugins: [nextCookies()],
|
||||||
|
|||||||
@@ -133,8 +133,6 @@ describe("environment template contract", () => {
|
|||||||
"DATABASE_URL",
|
"DATABASE_URL",
|
||||||
"BETTER_AUTH_URL",
|
"BETTER_AUTH_URL",
|
||||||
"BETTER_AUTH_SECRET",
|
"BETTER_AUTH_SECRET",
|
||||||
"GOOGLE_CLIENT_ID",
|
|
||||||
"GOOGLE_CLIENT_SECRET",
|
|
||||||
"ADMIN_EMAIL",
|
"ADMIN_EMAIL",
|
||||||
"REDIS_URL",
|
"REDIS_URL",
|
||||||
"S3_ENDPOINT",
|
"S3_ENDPOINT",
|
||||||
|
|||||||
Reference in New Issue
Block a user