diff --git a/CLAUDE.md b/CLAUDE.md index 8eea8a8..607dddb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ Tanpa folder `src/`. Kode aplikasi di root, sejajar config. ``` app/ (auth)/login/page.tsx halaman publik (belum login) - (dashboard)/layout.tsx cek session; semua halaman di dalamnya wajib login + (dashboard)/layout.tsx kerangka UI halaman yang wajib login (bukan tempat cek session) (dashboard)// page.tsx Server Component: ambil data lewat data/, render actions.ts Server Actions fitur ini ('use server') @@ -81,8 +81,13 @@ proxy.ts pengganti middleware di Next 16; hanya cek rin component. `db/` dan `lib/auth.ts` sengaja tanpa itu karena dibaca CLI `auth` di luar Next.js (package `server-only` tidak di-install; Next.js menanganinya sendiri). Hanya `data/`, `db/`, dan `lib/auth.ts` yang membaca `process.env` untuk secret. -- `proxy.ts` hanya untuk redirect cepat berdasarkan ada/tidaknya cookie session. Pemeriksaan - session yang sebenarnya tetap di layout/page dan di setiap action. +- Session dicek lewat `getCurrentUser()` di `data/auth.ts` (redirect ke `/login` kalau tidak ada), + dipanggil oleh page dan action yang butuh user. Layout tidak dipakai untuk cek session: layout + tidak di-render ulang saat navigasi antar halaman di dalamnya. +- `cacheComponents` aktif: komponen yang membaca session (`headers()`/`cookies()`) wajib berada + di dalam ``, kalau tidak build gagal. Jangan `await` session di level atas layout. +- `proxy.ts` hanya untuk redirect cepat berdasarkan ada/tidaknya cookie session, bukan pengganti + `getCurrentUser()`. - Query selalu lewat Drizzle query builder atau `sql` template tag (parameterized). Jangan menyusun SQL dengan string concatenation. diff --git a/PROGRESS.md b/PROGRESS.md new file mode 100644 index 0000000..b8dbb56 --- /dev/null +++ b/PROGRESS.md @@ -0,0 +1,73 @@ +# Progress + +Catatan sementara untuk melanjutkan pekerjaan di komputer lain. Hapus file ini kalau halaman +login sudah selesai. + +Terakhir diperbarui: 2026-10-20. + +## Sudah selesai + +- Drizzle ORM + `pg` ke Postgres (`db/`, `drizzle.config.ts`, script `db:*`). +- Better Auth 1.7: login pakai username, tabel auth sudah di-migrate, sign-up publik ditutup. + Endpoint di `app/api/auth/[...all]/route.ts`. +- shadcn/ui preset `base-nova`, komponen: button, input, label, card, field, separator. +- `old-source/` di-commit tanpa `db_login.inc.php` (kredensial). +- Halaman login, langkah 1 sampai 3 dari 7: + 1. `zod` terpasang. + 2. `data/auth.ts`: `getCurrentUser()` cek session, redirect ke `/login` kalau belum login. + 3. `app/(auth)/login/actions.ts`: server action `login` (validasi zod, lalu + `auth.api.signInUsername`, lalu `redirect("/")`). + +## Berikutnya: halaman login, langkah 4 sampai 7 + +4. **Form login (client component)** + `app/(auth)/login/_components/login-form.tsx`, diawali `"use client"`. + Pakai `useActionState(login, {})`. Isinya `Card` berisi `FieldGroup` dengan dua `Field` + (username dan password, `name="username"` / `name="password"`), `FieldError` untuk + `state.error`, dan `Button type="submit"` yang `disabled` saat `pending`. + Input username diberi `defaultValue={state.username}` supaya tidak kosong setelah gagal login. + +5. **Halaman login** + `app/(auth)/login/page.tsx`: Server Component yang menampilkan `` di tengah layar. + +6. **Pindahkan halaman utama ke grup dashboard** + Hapus `app/page.tsx` (bawaan create-next-app), lalu buat `app/(dashboard)/page.tsx`. + Komponen yang memanggil `getCurrentUser()` harus dibungkus `` karena + `cacheComponents` aktif (tanpa itu build gagal). Tampilkan nama user yang login. + +7. **Logout** + `app/(dashboard)/actions.ts` dengan `"use server"`: action `logout` memanggil + `auth.api.signOut({ headers: await headers() })` lalu `redirect("/login")`. + Pasang lewat `
` di + halaman dashboard. + +Cek akhir: buka `/` saat belum login, harus diarahkan ke `/login`. Login sebagai `admin`, +nama tampil di `/`. Klik Keluar, kembali ke `/login`. Lalu `pnpm exec tsc --noEmit` dan `pnpm lint`. + +Setelah itu: commit, lalu rencana berikutnya (belum dimulai): + +- User read-only (hanya `SELECT`) di MariaDB untuk `AMIGO_DATABASE_URL`, menggantikan `root`. +- Koneksi Drizzle ke MariaDB di `db/amigo/` dengan `drizzle-kit pull` (jangan pernah migrate/push). +- `proxy.ts` untuk redirect cepat ke `/login` kalau tidak ada cookie session. + +## Menyiapkan komputer lain + +Yang tidak ada di git dan harus disiapkan ulang: + +1. `.env`: isi `DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `AMIGO_DATABASE_URL`. + Buat `BETTER_AUTH_SECRET` baru: + `node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"` +2. Postgres lokal: + `docker run -d --name cleo-postgres -e POSTGRES_PASSWORD= -e POSTGRES_DB=cleo_auth -p 5432:5432 -v cleo-pgdata:/var/lib/postgresql/data postgres:17` +3. `pnpm install`, lalu `pnpm db:migrate` untuk membuat tabel auth. +4. Akun admin dibuat ulang. Sign-up sedang ditutup, jadi sementara hapus + `disableSignUp: true` di `lib/auth.ts`, jalankan `pnpm dev`, kirim request sign-up: + + ```powershell + $body = @{ name = "Administrator"; email = "admin@cleo.local"; username = "admin"; password = "" } | ConvertTo-Json + Invoke-RestMethod -Method Post -Uri "http://localhost:3000/api/auth/sign-up/email" -ContentType "application/json" -Headers @{ Origin = "http://localhost:3000" } -Body $body + ``` + + lalu kembalikan `disableSignUp: true`. +5. MariaDB `cleo` di `192.168.3.3` hanya bisa diakses dari jaringan kantor. Halaman login tidak + membutuhkannya. diff --git a/app/(auth)/login/actions.ts b/app/(auth)/login/actions.ts new file mode 100644 index 0000000..29a08fd --- /dev/null +++ b/app/(auth)/login/actions.ts @@ -0,0 +1,50 @@ +"use server"; + +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { isAPIError } from "better-auth/api"; +import { z } from "zod"; +import { auth } from "@/lib/auth"; + +const loginSchema = z.object({ + username: z.string().trim().min(1, "Username wajib diisi"), + password: z.string().min(1, "Password wajib diisi"), +}); + +export type LoginState = { + error?: string; + username?: string; +}; + +export async function login( + _prevState: LoginState, + formData: FormData, +): Promise { + const parsed = loginSchema.safeParse({ + username: formData.get("username"), + password: formData.get("password"), + }); + if (!parsed.success) { + return { + error: parsed.error.issues[0].message, + username: String(formData.get("username") ?? ""), + }; + } + + try { + await auth.api.signInUsername({ + body: parsed.data, + headers: await headers(), + }); + } catch (error) { + if (isAPIError(error)) { + return { + error: "Username atau password salah", + username: parsed.data.username, + }; + } + throw error; + } + + redirect("/"); +} diff --git a/data/auth.ts b/data/auth.ts new file mode 100644 index 0000000..96a6a10 --- /dev/null +++ b/data/auth.ts @@ -0,0 +1,21 @@ +import "server-only"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { auth } from "@/lib/auth"; + +export type CurrentUser = { + id: string; + name: string; + username: string | null; +}; + +export async function getCurrentUser(): Promise { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) redirect("/login"); + + return { + id: session.user.id, + name: session.user.name, + username: session.user.username ?? null, + }; +} diff --git a/package.json b/package.json index e388409..19abd76 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "react": "19.3.0", "react-dom": "19.3.0", "shadcn": "^4.22.1", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "zod": "^4.6.7" }, "devDependencies": { "@tailwindcss/turbopack": "^4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 672ca25..9928f3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + zod: + specifier: ^4.6.7 + version: 4.6.7 devDependencies: '@tailwindcss/turbopack': specifier: ^4