@blawness/admin-kit 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +14 -0
  2. package/dist/auth/config.d.ts +3 -0
  3. package/dist/auth/config.d.ts.map +1 -0
  4. package/dist/auth/config.js +36 -0
  5. package/dist/auth/index.d.ts +6 -0
  6. package/dist/auth/index.d.ts.map +1 -0
  7. package/dist/auth/index.js +46 -0
  8. package/dist/components/admin/editor.d.ts +5 -0
  9. package/dist/components/admin/editor.d.ts.map +1 -0
  10. package/dist/components/admin/editor.js +28 -0
  11. package/dist/components/admin/image-upload.d.ts +15 -0
  12. package/dist/components/admin/image-upload.d.ts.map +1 -0
  13. package/dist/components/admin/image-upload.js +50 -0
  14. package/dist/components/admin/toast-on-param.d.ts +5 -0
  15. package/dist/components/admin/toast-on-param.d.ts.map +1 -0
  16. package/dist/components/admin/toast-on-param.js +31 -0
  17. package/dist/components/confirm-delete.d.ts +14 -0
  18. package/dist/components/confirm-delete.d.ts.map +1 -0
  19. package/dist/components/confirm-delete.js +32 -0
  20. package/dist/components/ui/button.d.ts +9 -0
  21. package/dist/components/ui/button.d.ts.map +1 -0
  22. package/dist/components/ui/button.js +34 -0
  23. package/dist/components/ui/dialog.d.ts +18 -0
  24. package/dist/components/ui/dialog.d.ts.map +1 -0
  25. package/dist/components/ui/dialog.js +37 -0
  26. package/dist/components/ui/input.d.ts +4 -0
  27. package/dist/components/ui/input.d.ts.map +1 -0
  28. package/dist/components/ui/input.js +7 -0
  29. package/dist/components.d.ts +7 -0
  30. package/dist/components.d.ts.map +1 -0
  31. package/dist/components.js +6 -0
  32. package/dist/db/index.d.ts +6 -0
  33. package/dist/db/index.d.ts.map +1 -0
  34. package/dist/db/index.js +8 -0
  35. package/dist/db/schema.d.ts +202 -0
  36. package/dist/db/schema.d.ts.map +1 -0
  37. package/dist/db/schema.js +16 -0
  38. package/dist/index.d.ts +8 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +7 -0
  41. package/dist/lib/admin/media.d.ts +16 -0
  42. package/dist/lib/admin/media.d.ts.map +1 -0
  43. package/dist/lib/admin/media.js +13 -0
  44. package/dist/lib/admin/users.d.ts +12 -0
  45. package/dist/lib/admin/users.d.ts.map +1 -0
  46. package/dist/lib/admin/users.js +24 -0
  47. package/dist/lib/auth-helpers.d.ts +5 -0
  48. package/dist/lib/auth-helpers.d.ts.map +1 -0
  49. package/dist/lib/auth-helpers.js +16 -0
  50. package/dist/lib/db-errors.d.ts +5 -0
  51. package/dist/lib/db-errors.d.ts.map +1 -0
  52. package/dist/lib/db-errors.js +14 -0
  53. package/dist/lib/r2.d.ts +24 -0
  54. package/dist/lib/r2.d.ts.map +1 -0
  55. package/dist/lib/r2.js +59 -0
  56. package/dist/lib/sanitize.d.ts +14 -0
  57. package/dist/lib/sanitize.d.ts.map +1 -0
  58. package/dist/lib/sanitize.js +28 -0
  59. package/dist/lib/slug.d.ts +2 -0
  60. package/dist/lib/slug.d.ts.map +1 -0
  61. package/dist/lib/slug.js +9 -0
  62. package/dist/lib/utils.d.ts +3 -0
  63. package/dist/lib/utils.d.ts.map +1 -0
  64. package/dist/lib/utils.js +5 -0
  65. package/dist/screens/login/actions.d.ts +5 -0
  66. package/dist/screens/login/actions.d.ts.map +1 -0
  67. package/dist/screens/login/actions.js +28 -0
  68. package/dist/screens/login/page.d.ts +3 -0
  69. package/dist/screens/login/page.d.ts.map +1 -0
  70. package/dist/screens/login/page.js +11 -0
  71. package/dist/screens/media/actions.d.ts +5 -0
  72. package/dist/screens/media/actions.d.ts.map +1 -0
  73. package/dist/screens/media/actions.js +32 -0
  74. package/dist/screens/media/lib.d.ts +9 -0
  75. package/dist/screens/media/lib.d.ts.map +1 -0
  76. package/dist/screens/media/lib.js +30 -0
  77. package/dist/screens/media/page.d.ts +8 -0
  78. package/dist/screens/media/page.d.ts.map +1 -0
  79. package/dist/screens/media/page.js +13 -0
  80. package/dist/screens/media/uploader.d.ts +2 -0
  81. package/dist/screens/media/uploader.d.ts.map +1 -0
  82. package/dist/screens/media/uploader.js +10 -0
  83. package/dist/screens/users/actions.d.ts +5 -0
  84. package/dist/screens/users/actions.d.ts.map +1 -0
  85. package/dist/screens/users/actions.js +70 -0
  86. package/dist/screens/users/page.d.ts +7 -0
  87. package/dist/screens/users/page.d.ts.map +1 -0
  88. package/dist/screens/users/page.js +22 -0
  89. package/dist/shell/actions.d.ts +2 -0
  90. package/dist/shell/actions.d.ts.map +1 -0
  91. package/dist/shell/actions.js +5 -0
  92. package/dist/shell/layout.d.ts +9 -0
  93. package/dist/shell/layout.d.ts.map +1 -0
  94. package/dist/shell/layout.js +15 -0
  95. package/dist/shell/sidebar.d.ts +16 -0
  96. package/dist/shell/sidebar.d.ts.map +1 -0
  97. package/dist/shell/sidebar.js +19 -0
  98. package/package.json +148 -0
  99. package/src/auth/config.ts +36 -0
  100. package/src/auth/index.ts +49 -0
  101. package/src/components/admin/editor.tsx +53 -0
  102. package/src/components/admin/image-upload.tsx +128 -0
  103. package/src/components/admin/toast-on-param.tsx +47 -0
  104. package/src/components/confirm-delete.tsx +96 -0
  105. package/src/components/ui/button.tsx +58 -0
  106. package/src/components/ui/dialog.tsx +160 -0
  107. package/src/components/ui/input.tsx +20 -0
  108. package/src/components.ts +17 -0
  109. package/src/db/index.ts +8 -0
  110. package/src/db/schema.ts +23 -0
  111. package/src/index.ts +7 -0
  112. package/src/lib/admin/media.ts +16 -0
  113. package/src/lib/admin/users.ts +31 -0
  114. package/src/lib/auth-helpers.ts +16 -0
  115. package/src/lib/db-errors.ts +18 -0
  116. package/src/lib/r2.ts +70 -0
  117. package/src/lib/sanitize.ts +29 -0
  118. package/src/lib/slug.ts +9 -0
  119. package/src/lib/utils.ts +6 -0
  120. package/src/screens/login/actions.ts +38 -0
  121. package/src/screens/login/page.tsx +48 -0
  122. package/src/screens/media/actions.ts +34 -0
  123. package/src/screens/media/lib.ts +39 -0
  124. package/src/screens/media/page.tsx +82 -0
  125. package/src/screens/media/uploader.tsx +19 -0
  126. package/src/screens/users/actions.ts +71 -0
  127. package/src/screens/users/page.tsx +128 -0
  128. package/src/shell/actions.ts +6 -0
  129. package/src/shell/layout.tsx +47 -0
  130. package/src/shell/sidebar.tsx +74 -0
  131. package/src/types/next-auth.d.ts +20 -0
package/src/lib/r2.ts ADDED
@@ -0,0 +1,70 @@
1
+ import { S3Client, PutObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
2
+ import sharp from "sharp";
3
+
4
+ const endpoint = process.env.R2_ENDPOINT;
5
+ const accessKeyId = process.env.R2_ACCESS_KEY_ID;
6
+ const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY;
7
+
8
+ if (!endpoint) throw new Error("admin-kit: R2_ENDPOINT env var is required");
9
+ if (!accessKeyId || !secretAccessKey) throw new Error("admin-kit: R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY env vars are required");
10
+
11
+ export const R2_BUCKET = process.env.R2_BUCKET ?? "lipan-ri";
12
+ /** Base URL publik untuk menyajikan objek (r2.dev atau custom domain), tanpa trailing slash. */
13
+ export const R2_PUBLIC_URL = (process.env.R2_PUBLIC_URL ?? "").replace(/\/$/, "");
14
+
15
+ export const r2 = new S3Client({
16
+ region: "auto",
17
+ endpoint,
18
+ credentials:
19
+ accessKeyId && secretAccessKey
20
+ ? { accessKeyId, secretAccessKey }
21
+ : undefined,
22
+ });
23
+
24
+ /**
25
+ * Resize + kompres gambar lalu unggah ke R2.
26
+ * Apa pun ukuran input, hasil disimpan maksimal 1600px lebar, JPEG q80.
27
+ * Mengembalikan URL publik yang siap disimpan ke kolom `featured_image`.
28
+ */
29
+ export async function uploadImage(
30
+ input: Buffer,
31
+ /** key/nama objek di bucket, tanpa ekstensi — mis. "berita/slug-artikel" */
32
+ keyBase: string
33
+ ): Promise<{ url: string; key: string; size: number }> {
34
+ const optimized = await sharp(input)
35
+ .rotate() // hormati orientasi EXIF
36
+ .resize({ width: 1600, withoutEnlargement: true })
37
+ .jpeg({ quality: 80, mozjpeg: true })
38
+ .toBuffer();
39
+
40
+ const key = `${keyBase.replace(/^\/+/, "")}.jpg`;
41
+
42
+ await r2.send(
43
+ new PutObjectCommand({
44
+ Bucket: R2_BUCKET,
45
+ Key: key,
46
+ Body: optimized,
47
+ ContentType: "image/jpeg",
48
+ CacheControl: "public, max-age=31536000, immutable",
49
+ })
50
+ );
51
+
52
+ if (!R2_PUBLIC_URL) {
53
+ throw new Error("R2_PUBLIC_URL belum di-set — tidak bisa menyusun URL publik.");
54
+ }
55
+
56
+ return { url: `${R2_PUBLIC_URL}/${key}`, key, size: optimized.length };
57
+ }
58
+
59
+ /**
60
+ * Hapus objek dari R2 berdasarkan URL publiknya. Hanya menghapus objek yang
61
+ * memang berada di bawah R2_PUBLIC_URL — URL eksternal/seed diabaikan dengan
62
+ * aman (return false). Kegagalan jaringan dibiarkan melempar ke pemanggil.
63
+ */
64
+ export async function deleteObjectByUrl(url: string): Promise<boolean> {
65
+ if (!R2_PUBLIC_URL || !url.startsWith(`${R2_PUBLIC_URL}/`)) return false;
66
+ const key = url.slice(R2_PUBLIC_URL.length + 1);
67
+ if (!key) return false;
68
+ await r2.send(new DeleteObjectCommand({ Bucket: R2_BUCKET, Key: key }));
69
+ return true;
70
+ }
@@ -0,0 +1,29 @@
1
+ import sanitizeHtmlLib from "sanitize-html";
2
+
3
+ /**
4
+ * Sanitize editor HTML before persisting (content is rendered via
5
+ * dangerouslySetInnerHTML on the public site).
6
+ *
7
+ * Uses `sanitize-html` (pure JS, htmlparser2 — no jsdom) so it loads in the
8
+ * Vercel serverless runtime; `isomorphic-dompurify`/jsdom failed to load there
9
+ * ("Failed to load external module" → /admin/posts 500).
10
+ *
11
+ * Security posture (unchanged): strict tag allowlist, no `target` (avoids
12
+ * tab-napping), only http(s)/mailto + root-relative URLs, and protocol-relative
13
+ * `//host` URLs are rejected.
14
+ */
15
+ export function sanitizeHtml(dirty: string): string {
16
+ return sanitizeHtmlLib(dirty, {
17
+ allowedTags: [
18
+ "p", "br", "strong", "em", "u", "s", "h2", "h3", "h4",
19
+ "ul", "ol", "li", "blockquote", "a", "img", "figure", "figcaption",
20
+ ],
21
+ allowedAttributes: {
22
+ a: ["href", "rel", "title"],
23
+ img: ["src", "alt", "title"],
24
+ },
25
+ allowedSchemes: ["http", "https", "mailto"],
26
+ allowedSchemesByTag: { img: ["http", "https"] },
27
+ allowProtocolRelative: false,
28
+ });
29
+ }
@@ -0,0 +1,9 @@
1
+ export function slugify(input: string): string {
2
+ return input
3
+ .toLowerCase()
4
+ .normalize("NFKD")
5
+ .replace(/[̀-ͯ]/g, "")
6
+ .replace(/[^a-z0-9]+/g, "-")
7
+ .replace(/^-+|-+$/g, "")
8
+ .slice(0, 80);
9
+ }
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from "clsx"
2
+ import { twMerge } from "tailwind-merge"
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }
@@ -0,0 +1,38 @@
1
+ "use server";
2
+
3
+ import { redirect } from "next/navigation";
4
+ import { signIn } from "../../auth/index";
5
+
6
+ export type LoginState = { error?: string };
7
+
8
+ export async function loginAction(
9
+ _prev: LoginState,
10
+ formData: FormData,
11
+ ): Promise<LoginState> {
12
+ try {
13
+ const result = await signIn("credentials", {
14
+ email: formData.get("email"),
15
+ password: formData.get("password"),
16
+ redirect: false,
17
+ });
18
+
19
+ // Auth.js returns the redirect URL when redirect:false
20
+ // On failure it returns a URL with ?error= param
21
+ if (typeof result === "string" && result.includes("error=")) {
22
+ return { error: "Email atau password salah." };
23
+ }
24
+
25
+ redirect("/admin");
26
+ } catch (error) {
27
+ // Catch CredentialsSignin and other auth errors thrown by next-auth
28
+ // NEXT_REDIRECT thrown by redirect() must propagate
29
+ if (
30
+ error instanceof Error &&
31
+ "digest" in error &&
32
+ String((error as { digest: string }).digest).startsWith("NEXT_REDIRECT")
33
+ ) {
34
+ throw error;
35
+ }
36
+ return { error: "Email atau password salah." };
37
+ }
38
+ }
@@ -0,0 +1,48 @@
1
+ "use client";
2
+
3
+ import { useActionState } from "react";
4
+ import { loginAction, type LoginState } from "./actions";
5
+ import { Button } from "../../components/ui/button";
6
+ import { Input } from "../../components/ui/input";
7
+
8
+ export const dynamic = "force-dynamic";
9
+
10
+ export default function LoginScreen() {
11
+ const [state, action, pending] = useActionState<LoginState, FormData>(
12
+ loginAction,
13
+ {},
14
+ );
15
+
16
+ return (
17
+ <div className="min-h-screen flex items-center justify-center bg-navy-50 px-4">
18
+ <form
19
+ action={action}
20
+ className="w-full max-w-sm bg-white rounded-2xl shadow-xl ring-1 ring-navy-100 p-8 space-y-4"
21
+ >
22
+ <h1 className="font-heading text-2xl font-bold text-navy-900">
23
+ Masuk Admin
24
+ </h1>
25
+ <div className="space-y-1">
26
+ <label htmlFor="email" className="text-sm font-medium text-navy-900">
27
+ Email
28
+ </label>
29
+ <Input id="email" name="email" type="email" required autoComplete="email" />
30
+ </div>
31
+ <div className="space-y-1">
32
+ <label htmlFor="password" className="text-sm font-medium text-navy-900">
33
+ Password
34
+ </label>
35
+ <Input id="password" name="password" type="password" required autoComplete="current-password" />
36
+ </div>
37
+ {state.error && (
38
+ <p className="text-sm text-red-600" role="alert">
39
+ {state.error}
40
+ </p>
41
+ )}
42
+ <Button type="submit" className="w-full" disabled={pending}>
43
+ {pending ? "Memproses…" : "Masuk"}
44
+ </Button>
45
+ </form>
46
+ </div>
47
+ );
48
+ }
@@ -0,0 +1,34 @@
1
+ "use server";
2
+
3
+ import { revalidatePath } from "next/cache";
4
+ import { requireUser } from "../../lib/auth-helpers";
5
+ import { uploadImage } from "../../lib/r2";
6
+ import { db } from "../../db/index";
7
+ import { media } from "../../db/schema";
8
+
9
+ const MAX_BYTES = 8 * 1024 * 1024;
10
+ const OK_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
11
+
12
+ export async function uploadImageAction(formData: FormData): Promise<{ url?: string; error?: string }> {
13
+ await requireUser();
14
+ const file = formData.get("file");
15
+ if (!(file instanceof File)) return { error: "Tidak ada berkas." };
16
+ if (!OK_TYPES.includes(file.type)) return { error: "Format gambar tidak didukung." };
17
+ if (file.size > MAX_BYTES) return { error: "Ukuran gambar maksimal 8MB." };
18
+
19
+ const buf = Buffer.from(await file.arrayBuffer());
20
+ const keyBase = `uploads/${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
21
+
22
+ let url: string;
23
+ try {
24
+ // sharp (inside uploadImage) throws on data that isn't really an image,
25
+ // e.g. a non-image file sent with a spoofed image MIME type.
26
+ ({ url } = await uploadImage(buf, keyBase));
27
+ } catch {
28
+ return { error: "Gambar gagal diproses. Pastikan berkas benar-benar gambar." };
29
+ }
30
+
31
+ await db.insert(media).values({ url, altText: file.name });
32
+ revalidatePath("/admin/media");
33
+ return { url };
34
+ }
@@ -0,0 +1,39 @@
1
+ import { redirect } from "next/navigation";
2
+ import { requireUser } from "../../lib/auth-helpers";
3
+ import { deleteObjectByUrl } from "../../lib/r2";
4
+ import { getMediaById, deleteMediaRow } from "../../lib/admin/media";
5
+ import { revalidatePath } from "next/cache";
6
+
7
+ /**
8
+ * Generic delete-media logic, extracted from the server action so that the
9
+ * consuming app can inject its own reference checker (posts, banners, etc.).
10
+ *
11
+ * @param formData - FormData containing "id" field
12
+ * @param referenceChecker - async fn that returns the count of references to the given URL
13
+ */
14
+ export async function handleDeleteMedia(
15
+ formData: FormData,
16
+ referenceChecker: (url: string) => Promise<number>,
17
+ ): Promise<void> {
18
+ await requireUser();
19
+ const id = Number(formData.get("id"));
20
+ if (!id) return;
21
+
22
+ const row = await getMediaById(id);
23
+ if (!row) return;
24
+
25
+ const refs = await referenceChecker(row.url);
26
+ if (refs > 0) {
27
+ redirect(
28
+ `/admin/media?error=${encodeURIComponent(
29
+ `Gambar masih dipakai oleh ${refs} konten. Lepas dulu dari berita/banner sebelum menghapus.`
30
+ )}`
31
+ );
32
+ }
33
+
34
+ // Hapus objek R2 lebih dulu; bila gagal, biarkan melempar agar row DB tetap
35
+ // ada dan operasi bisa diulang (hindari row hilang tapi objek menggantung).
36
+ await deleteObjectByUrl(row.url);
37
+ await deleteMediaRow(id);
38
+ revalidatePath("/admin/media");
39
+ }
@@ -0,0 +1,82 @@
1
+ import { listMedia } from "../../lib/admin/media";
2
+ import { requireUser } from "../../lib/auth-helpers";
3
+ import { ConfirmDelete } from "../../components/confirm-delete";
4
+ import { GalleryUploader } from "./uploader";
5
+ import { Trash2, ImageOff, AlertCircle } from "lucide-react";
6
+
7
+ export const dynamic = "force-dynamic";
8
+
9
+ export default async function MediaLibraryScreen({
10
+ deleteAction,
11
+ searchParams,
12
+ }: {
13
+ deleteAction: (fd: FormData) => Promise<void>;
14
+ searchParams: Promise<{ error?: string }>;
15
+ }) {
16
+ await requireUser();
17
+ const items = await listMedia();
18
+ const { error } = await searchParams;
19
+
20
+ return (
21
+ <div className="max-w-5xl">
22
+ <div className="mb-6">
23
+ <h1 className="font-heading text-2xl font-bold text-navy-900">Galeri</h1>
24
+ <p className="mt-1 text-sm text-muted-foreground">
25
+ {items.length} gambar tersimpan
26
+ </p>
27
+ </div>
28
+
29
+ {error && (
30
+ <p className="mb-4 flex items-center gap-1.5 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600 ring-1 ring-red-100" role="alert">
31
+ <AlertCircle className="h-4 w-4 shrink-0" />
32
+ {error}
33
+ </p>
34
+ )}
35
+
36
+ <GalleryUploader />
37
+
38
+ {items.length === 0 ? (
39
+ <div className="mt-6 flex flex-col items-center justify-center rounded-xl border border-dashed border-navy-200 bg-white py-16 text-center">
40
+ <ImageOff className="h-8 w-8 text-navy-300" />
41
+ <p className="mt-3 text-sm font-medium text-navy-700">Belum ada gambar</p>
42
+ <p className="text-xs text-muted-foreground">
43
+ Unggah gambar pertama lewat kotak di atas.
44
+ </p>
45
+ </div>
46
+ ) : (
47
+ <div className="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
48
+ {items.map((m) => (
49
+ <div
50
+ key={m.id}
51
+ className="group relative overflow-hidden rounded-xl border border-navy-100 bg-white shadow-sm"
52
+ >
53
+ {/* eslint-disable-next-line @next/next/no-img-element -- R2 URL */}
54
+ <img
55
+ src={m.url}
56
+ alt={m.altText ?? ""}
57
+ className="aspect-square w-full object-cover transition-transform duration-300 group-hover:scale-105"
58
+ />
59
+ <div className="absolute right-2 top-2">
60
+ <ConfirmDelete
61
+ action={deleteAction}
62
+ id={m.id}
63
+ title="Hapus gambar?"
64
+ description="Gambar akan dihapus permanen dari media."
65
+ trigger={
66
+ <button
67
+ type="button"
68
+ aria-label="Hapus gambar"
69
+ className="flex h-8 w-8 items-center justify-center rounded-full bg-white/90 text-navy-600 opacity-100 shadow-sm ring-1 ring-navy-100 backdrop-blur transition-all hover:bg-red-50 hover:text-red-600 sm:opacity-0 sm:group-hover:opacity-100"
70
+ >
71
+ <Trash2 className="h-4 w-4" />
72
+ </button>
73
+ }
74
+ />
75
+ </div>
76
+ </div>
77
+ ))}
78
+ </div>
79
+ )}
80
+ </div>
81
+ );
82
+ }
@@ -0,0 +1,19 @@
1
+ "use client";
2
+
3
+ import { useRouter } from "next/navigation";
4
+ import { Images } from "lucide-react";
5
+ import { ImageUpload } from "../../components/admin/image-upload";
6
+ import { uploadImageAction } from "./actions";
7
+
8
+ export function GalleryUploader() {
9
+ const router = useRouter();
10
+ return (
11
+ <div className="max-w-xl rounded-xl border border-navy-100 bg-white p-5 shadow-sm">
12
+ <div className="mb-3 flex items-center gap-2">
13
+ <Images className="h-4 w-4 text-brand-600" />
14
+ <p className="text-sm font-semibold text-navy-900">Tambah gambar ke galeri</p>
15
+ </div>
16
+ <ImageUpload uploadAction={uploadImageAction} onChange={() => router.refresh()} />
17
+ </div>
18
+ );
19
+ }
@@ -0,0 +1,71 @@
1
+ "use server";
2
+
3
+ import { revalidatePath } from "next/cache";
4
+ import { redirect } from "next/navigation";
5
+ import { z } from "zod";
6
+ import { requireAdmin } from "../../lib/auth-helpers";
7
+ import { isUniqueViolation, isForeignKeyViolation } from "../../lib/db-errors";
8
+ import { createUser, updateUserPassword, updateUserRole, deleteUser } from "../../lib/admin/users";
9
+
10
+ const createSchema = z.object({
11
+ email: z.string().email(),
12
+ name: z.string().min(1),
13
+ password: z.string().min(8),
14
+ role: z.enum(["admin", "editor"]),
15
+ });
16
+
17
+ export async function createUserAction(formData: FormData) {
18
+ await requireAdmin();
19
+ const parsed = createSchema.safeParse({
20
+ email: formData.get("email"),
21
+ name: formData.get("name"),
22
+ password: formData.get("password"),
23
+ role: formData.get("role"),
24
+ });
25
+ if (!parsed.success) {
26
+ redirect("/admin/users?error=Data+tidak+valid+(email+benar,+password+min+8+karakter)");
27
+ }
28
+ const { email, name, password, role } = parsed.data;
29
+ try {
30
+ await createUser(email, name, password, role);
31
+ } catch (e) {
32
+ if (isUniqueViolation(e)) {
33
+ redirect("/admin/users?error=Email+sudah+terdaftar");
34
+ }
35
+ throw e;
36
+ }
37
+ revalidatePath("/admin/users");
38
+ }
39
+
40
+ export async function resetPasswordAction(formData: FormData) {
41
+ await requireAdmin();
42
+ const id = Number(formData.get("id"));
43
+ const password = String(formData.get("password") ?? "");
44
+ if (!id || password.length < 8) return;
45
+ await updateUserPassword(id, password);
46
+ revalidatePath("/admin/users");
47
+ }
48
+
49
+ export async function setRoleAction(formData: FormData) {
50
+ await requireAdmin();
51
+ const id = Number(formData.get("id"));
52
+ const role = String(formData.get("role") ?? "");
53
+ if (!id || (role !== "admin" && role !== "editor")) return;
54
+ await updateUserRole(id, role);
55
+ revalidatePath("/admin/users");
56
+ }
57
+
58
+ export async function deleteUserAction(formData: FormData) {
59
+ const session = await requireAdmin();
60
+ const id = Number(formData.get("id"));
61
+ if (!id || id === Number(session.user.id)) return; // never delete yourself / invalid id
62
+ try {
63
+ await deleteUser(id);
64
+ } catch (e) {
65
+ if (isForeignKeyViolation(e)) {
66
+ redirect("/admin/users?error=User+ini+masih+menjadi+penulis+berita");
67
+ }
68
+ throw e;
69
+ }
70
+ revalidatePath("/admin/users");
71
+ }
@@ -0,0 +1,128 @@
1
+ import { requireAdmin } from "../../lib/auth-helpers";
2
+ import { listUsers } from "../../lib/admin/users";
3
+ import { Button } from "../../components/ui/button";
4
+ import { Input } from "../../components/ui/input";
5
+ import { ConfirmDelete } from "../../components/confirm-delete";
6
+ import { createUserAction, resetPasswordAction, setRoleAction, deleteUserAction } from "./actions";
7
+ import { UserPlus, KeyRound, AlertCircle } from "lucide-react";
8
+
9
+ export const dynamic = "force-dynamic";
10
+
11
+ const selectClass =
12
+ "h-9 rounded-md border border-navy-200 bg-white px-2.5 text-sm text-navy-900 outline-none transition-colors focus:border-brand-400 focus:ring-2 focus:ring-brand-100";
13
+
14
+ export default async function UsersScreen({
15
+ searchParams,
16
+ }: {
17
+ searchParams: Promise<{ error?: string }>;
18
+ }) {
19
+ const session = await requireAdmin();
20
+ const rows = await listUsers();
21
+ const { error } = await searchParams;
22
+
23
+ return (
24
+ <div className="max-w-3xl">
25
+ <h1 className="font-heading text-2xl font-bold text-navy-900">User</h1>
26
+ <p className="mt-1 text-sm text-muted-foreground">{rows.length} akun</p>
27
+
28
+ {error && (
29
+ <p className="mt-4 flex items-center gap-1.5 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600 ring-1 ring-red-100" role="alert">
30
+ <AlertCircle className="h-4 w-4 shrink-0" />
31
+ {error}
32
+ </p>
33
+ )}
34
+
35
+ <form
36
+ action={createUserAction}
37
+ className="mt-6 grid grid-cols-1 gap-2.5 rounded-xl border border-navy-100 bg-white p-5 shadow-sm sm:grid-cols-2"
38
+ >
39
+ <p className="flex items-center gap-2 text-sm font-semibold text-navy-900 sm:col-span-2">
40
+ <UserPlus className="h-4 w-4 text-brand-600" />
41
+ Tambah user baru
42
+ </p>
43
+ <Input name="name" placeholder="Nama" required />
44
+ <Input name="email" type="email" placeholder="Email" required />
45
+ <Input name="password" type="password" placeholder="Password (min 8)" required />
46
+ <select name="role" className={selectClass}>
47
+ <option value="editor">Editor</option>
48
+ <option value="admin">Admin</option>
49
+ </select>
50
+ <Button type="submit" className="sm:col-span-2">
51
+ <UserPlus className="h-4 w-4" />
52
+ Tambah User
53
+ </Button>
54
+ </form>
55
+
56
+ <ul className="mt-6 divide-y divide-navy-50 overflow-hidden rounded-xl border border-navy-100 bg-white shadow-sm">
57
+ {rows.map((u) => {
58
+ const isSelf = u.id === Number(session.user.id);
59
+ const isAdmin = (u.role ?? "editor") === "admin";
60
+ return (
61
+ <li key={u.id} className="space-y-3 p-4">
62
+ <div className="flex items-center justify-between gap-3">
63
+ <div className="flex min-w-0 items-center gap-3">
64
+ <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-navy-100 text-sm font-semibold uppercase text-navy-700">
65
+ {u.name?.[0] ?? u.email[0]}
66
+ </span>
67
+ <div className="min-w-0">
68
+ <p className="flex items-center gap-2 font-medium text-navy-900">
69
+ <span className="truncate">{u.name}</span>
70
+ {isSelf && (
71
+ <span className="rounded bg-navy-100 px-1.5 py-0.5 text-[10px] font-medium text-navy-500">
72
+ Anda
73
+ </span>
74
+ )}
75
+ </p>
76
+ <p className="truncate text-xs text-muted-foreground">{u.email}</p>
77
+ </div>
78
+ </div>
79
+ <span
80
+ className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize ring-1 ${
81
+ isAdmin
82
+ ? "bg-gold-100 text-gold-700 ring-gold-200"
83
+ : "bg-brand-50 text-brand-700 ring-brand-100"
84
+ }`}
85
+ >
86
+ {u.role ?? "editor"}
87
+ </span>
88
+ </div>
89
+
90
+ <div className="flex flex-wrap items-center gap-2 border-t border-navy-50 pt-3">
91
+ <form action={setRoleAction} className="flex items-center gap-2">
92
+ <input type="hidden" name="id" value={u.id} />
93
+ <select name="role" defaultValue={u.role ?? "editor"} className="h-8 rounded-md border border-navy-200 bg-white px-2 text-xs">
94
+ <option value="editor">Editor</option>
95
+ <option value="admin">Admin</option>
96
+ </select>
97
+ <Button size="sm" variant="outline" type="submit">Set Role</Button>
98
+ </form>
99
+ <form action={resetPasswordAction} className="flex items-center gap-2">
100
+ <input type="hidden" name="id" value={u.id} />
101
+ <Input name="password" type="password" placeholder="Password baru" className="h-8 w-36" />
102
+ <Button size="sm" variant="outline" type="submit">
103
+ <KeyRound className="h-3.5 w-3.5" />
104
+ Reset
105
+ </Button>
106
+ </form>
107
+ {!isSelf && (
108
+ <div className="ml-auto">
109
+ <ConfirmDelete
110
+ action={deleteUserAction}
111
+ id={u.id}
112
+ title="Hapus user?"
113
+ description={
114
+ <>
115
+ User <span className="font-medium text-navy-900">{u.email}</span> akan dihapus.
116
+ </>
117
+ }
118
+ />
119
+ </div>
120
+ )}
121
+ </div>
122
+ </li>
123
+ );
124
+ })}
125
+ </ul>
126
+ </div>
127
+ );
128
+ }
@@ -0,0 +1,6 @@
1
+ "use server";
2
+ import { signOut } from "../auth/index";
3
+
4
+ export async function signOutAction() {
5
+ await signOut({ redirectTo: "/admin/login" });
6
+ }
@@ -0,0 +1,47 @@
1
+ import type { ReactNode } from "react";
2
+ import { auth } from "../auth/index";
3
+ import { Toaster } from "sonner";
4
+ import { AdminSidebar, type NavItem } from "./sidebar";
5
+
6
+ export async function AdminLayout({
7
+ navItems,
8
+ children,
9
+ logoSrc,
10
+ brandName,
11
+ }: {
12
+ navItems: NavItem[];
13
+ children: ReactNode;
14
+ logoSrc?: string;
15
+ brandName?: string;
16
+ }) {
17
+ const session = await auth();
18
+
19
+ // The only unauthenticated route reachable here is /admin/login: the proxy
20
+ // redirects every other /admin/* to login, and each admin page additionally
21
+ // calls requireUser()/requireAdmin() before touching data.
22
+ // So rendering bare children here (no shell) cannot leak protected content.
23
+ if (!session?.user) {
24
+ return <>{children}</>;
25
+ }
26
+
27
+ return (
28
+ <div className="flex min-h-screen bg-navy-50/60">
29
+ <AdminSidebar role={session.user.role} navItems={navItems} logoSrc={logoSrc} brandName={brandName} />
30
+ <div className="flex min-w-0 flex-1 flex-col">
31
+ <header className="sticky top-0 z-10 flex h-16 items-center justify-between border-b border-navy-100 bg-white/80 px-6 backdrop-blur-sm">
32
+ <span className="text-sm font-medium text-navy-500">Panel Admin</span>
33
+ <div className="flex items-center gap-2.5">
34
+ <span className="hidden text-sm text-navy-600 sm:inline">
35
+ {session.user.email}
36
+ </span>
37
+ <span className="inline-flex items-center rounded-full bg-brand-50 px-2.5 py-1 text-xs font-semibold capitalize text-brand-700 ring-1 ring-brand-100">
38
+ {session.user.role}
39
+ </span>
40
+ </div>
41
+ </header>
42
+ <main className="flex-1 p-6 md:p-8">{children}</main>
43
+ </div>
44
+ <Toaster />
45
+ </div>
46
+ );
47
+ }