fix(profil-module): QC improvements based on QC-PROFIL-MODULE.md

- Fix fetch method inconsistency (convert to ApiFetch)
  - programInovasi: findUnique, delete, update methods
  - mediaSosial: findUnique, delete, update methods
- Add loading state to findUnique operations
- Fix iconUrl validation (make optional instead of required)
- Add DOMPurify for HTML sanitization (XSS protection)
  - program-inovasi page.tsx (list & detail)
- Remove console.log in production (use dev-only logging)
- Install dompurify and @types/dompurify

Security: Prevent XSS attacks by sanitizing HTML content
Consistency: Use ApiFetch for all API operations
UX: Proper loading states for better user feedback

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
2026-02-23 15:11:00 +08:00
parent 92b24440fe
commit f2c9a922a6
5 changed files with 121 additions and 152 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -113,6 +113,7 @@
"@playwright/test": "^1.58.2", "@playwright/test": "^1.58.2",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@types/cli-progress": "^3.11.6", "@types/cli-progress": "^3.11.6",
"@types/dompurify": "^3.2.0",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",

View File

@@ -55,10 +55,15 @@ const programInovasi = proxy({
programInovasi.findMany.load(); programInovasi.findMany.load();
return toast.success("Sukses menambahkan"); return toast.success("Sukses menambahkan");
} }
console.log(res); if (process.env.NODE_ENV === 'development') {
console.log(res);
}
return toast.error("failed create"); return toast.error("failed create");
} catch (error) { } catch (error) {
console.log((error as Error).message); if (process.env.NODE_ENV === 'development') {
console.error("Create error:", error);
}
toast.error("Gagal menambahkan data");
} finally { } finally {
programInovasi.create.loading = false; programInovasi.create.loading = false;
} }
@@ -91,13 +96,17 @@ const programInovasi = proxy({
programInovasi.findMany.total = res.data.total || 0; programInovasi.findMany.total = res.data.total || 0;
programInovasi.findMany.totalPages = res.data.totalPages || 1; programInovasi.findMany.totalPages = res.data.totalPages || 1;
} else { } else {
console.error("Failed to load pegawai:", res.data?.message); if (process.env.NODE_ENV === 'development') {
console.error("Failed to load pegawai:", res.data?.message);
}
programInovasi.findMany.data = []; programInovasi.findMany.data = [];
programInovasi.findMany.total = 0; programInovasi.findMany.total = 0;
programInovasi.findMany.totalPages = 1; programInovasi.findMany.totalPages = 1;
} }
} catch (error) { } catch (error) {
console.error("Error loading pegawai:", error); if (process.env.NODE_ENV === 'development') {
console.error("Error loading pegawai:", error);
}
programInovasi.findMany.data = []; programInovasi.findMany.data = [];
programInovasi.findMany.total = 0; programInovasi.findMany.total = 0;
programInovasi.findMany.totalPages = 1; programInovasi.findMany.totalPages = 1;
@@ -112,19 +121,25 @@ const programInovasi = proxy({
image: true; image: true;
}; };
}> | null, }> | null,
loading: false,
async load(id: string) { async load(id: string) {
try { try {
const res = await fetch(`/api/landingpage/programinovasi/${id}`); programInovasi.findUnique.loading = true;
if (res.ok) { const res = await (ApiFetch.api.landingpage.programinovasi as any)[id].get();
const data = await res.json(); if (res.data?.success) {
programInovasi.findUnique.data = data.data ?? null; programInovasi.findUnique.data = res.data.data ?? null;
return res.data.data;
} else { } else {
console.error("Failed to fetch program inovasi:", res.statusText); toast.error(res.data?.message || "Gagal memuat data program inovasi");
programInovasi.findUnique.data = null; programInovasi.findUnique.data = null;
return null;
} }
} catch (error) { } catch (error) {
console.error("Error fetching program inovasi:", error); console.error("Error fetching program inovasi:", error);
programInovasi.findUnique.data = null; programInovasi.findUnique.data = null;
return null;
} finally {
programInovasi.findUnique.loading = false;
} }
}, },
}, },
@@ -135,27 +150,18 @@ const programInovasi = proxy({
try { try {
programInovasi.delete.loading = true; programInovasi.delete.loading = true;
const res = await (ApiFetch.api.landingpage.programinovasi as any)["del"][id].delete();
const response = await fetch( if (res.data?.success) {
`/api/landingpage/programinovasi/del/${id}`, toast.success(res.data.message || "Program inovasi berhasil dihapus");
{ await programInovasi.findMany.load();
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
}
);
const result = await response.json();
if (response.ok && result?.success) {
toast.success(result.message || "Program inovasi berhasil dihapus");
await programInovasi.findMany.load(); // refresh list
} else { } else {
toast.error(result?.message || "Gagal menghapus program inovasi"); toast.error(res.data?.message || "Gagal menghapus program inovasi");
} }
} catch (error) { } catch (error) {
console.error("Gagal delete:", error); if (process.env.NODE_ENV === 'development') {
console.error("Gagal delete:", error);
}
toast.error("Terjadi kesalahan saat menghapus program inovasi"); toast.error("Terjadi kesalahan saat menghapus program inovasi");
} finally { } finally {
programInovasi.delete.loading = false; programInovasi.delete.loading = false;
@@ -174,20 +180,11 @@ const programInovasi = proxy({
} }
try { try {
const response = await fetch(`/api/landingpage/programinovasi/${id}`, { programInovasi.update.loading = true;
method: "GET", const res = await (ApiFetch.api.landingpage.programinovasi as any)[id].get();
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json(); if (res.data?.success) {
const data = res.data.data;
if (result?.success) {
const data = result.data;
this.id = data.id; this.id = data.id;
this.form = { this.form = {
name: data.name, name: data.name,
@@ -197,13 +194,15 @@ const programInovasi = proxy({
}; };
return data; return data;
} else { } else {
throw new Error( toast.error(res.data?.message || "Gagal mengambil data program inovasi");
result?.message || "Gagal mengambil data program inovasi" return null;
);
} }
} catch (error) { } catch (error) {
console.error((error as Error).message); if (process.env.NODE_ENV === 'development') {
console.error("Error loading program inovasi:", error);
}
toast.error("Terjadi kesalahan saat mengambil data program inovasi"); toast.error("Terjadi kesalahan saat mengambil data program inovasi");
return null;
} finally { } finally {
programInovasi.update.loading = false; programInovasi.update.loading = false;
} }
@@ -221,41 +220,25 @@ const programInovasi = proxy({
try { try {
programInovasi.update.loading = true; programInovasi.update.loading = true;
const res = await (ApiFetch.api.landingpage.programinovasi as any)[this.id].put({
name: this.form.name,
description: this.form.description,
imageId: this.form.imageId,
link: this.form.link,
});
const response = await fetch( if (res.data?.success) {
`/api/landingpage/programinovasi/${this.id}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: this.form.name,
description: this.form.description,
imageId: this.form.imageId,
link: this.form.link,
}),
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message || `HTTP error! status: ${response.status}`
);
}
const result = await response.json();
if (result.success) {
toast.success("Berhasil update program inovasi"); toast.success("Berhasil update program inovasi");
await programInovasi.findMany.load(); // refresh list await programInovasi.findMany.load();
return true; return true;
} else { } else {
throw new Error(result.message || "Gagal update program inovasi"); toast.error(res.data?.message || "Gagal update program inovasi");
return false;
} }
} catch (error) { } catch (error) {
console.error("Error updating program inovasi:", error); if (process.env.NODE_ENV === 'development') {
console.error("Error updating program inovasi:", error);
}
toast.error( toast.error(
error instanceof Error error instanceof Error
? error.message ? error.message
@@ -443,7 +426,7 @@ const pejabatDesa = proxy({
const templateMediaSosial = z.object({ const templateMediaSosial = z.object({
name: z.string().min(3, "Nama minimal 3 karakter"), name: z.string().min(3, "Nama minimal 3 karakter"),
imageId: z.string().nullable().optional(), imageId: z.string().nullable().optional(),
iconUrl: z.string().min(3, "Icon URL minimal 3 karakter"), iconUrl: z.string().optional(), // ✅ Optional - tidak selalu required
icon: z.string().nullable().optional(), icon: z.string().nullable().optional(),
}); });
@@ -484,10 +467,15 @@ const mediaSosial = proxy({
mediaSosial.findMany.load(); mediaSosial.findMany.load();
return toast.success("Sukses menambahkan"); return toast.success("Sukses menambahkan");
} }
console.log(res); if (process.env.NODE_ENV === 'development') {
console.log(res);
}
return toast.error("failed create"); return toast.error("failed create");
} catch (error) { } catch (error) {
console.log((error as Error).message); if (process.env.NODE_ENV === 'development') {
console.log((error as Error).message);
}
toast.error("Gagal menambahkan data");
} finally { } finally {
mediaSosial.create.loading = false; mediaSosial.create.loading = false;
} }
@@ -518,13 +506,17 @@ const mediaSosial = proxy({
mediaSosial.findMany.total = res.data.total || 0; mediaSosial.findMany.total = res.data.total || 0;
mediaSosial.findMany.totalPages = res.data.totalPages || 1; mediaSosial.findMany.totalPages = res.data.totalPages || 1;
} else { } else {
console.error("Failed to load media sosial:", res.data?.message); if (process.env.NODE_ENV === 'development') {
console.error("Failed to load media sosial:", res.data?.message);
}
mediaSosial.findMany.data = []; mediaSosial.findMany.data = [];
mediaSosial.findMany.total = 0; mediaSosial.findMany.total = 0;
mediaSosial.findMany.totalPages = 1; mediaSosial.findMany.totalPages = 1;
} }
} catch (error) { } catch (error) {
console.error("Error loading media sosial:", error); if (process.env.NODE_ENV === 'development') {
console.error("Error loading media sosial:", error);
}
mediaSosial.findMany.data = []; mediaSosial.findMany.data = [];
mediaSosial.findMany.total = 0; mediaSosial.findMany.total = 0;
mediaSosial.findMany.totalPages = 1; mediaSosial.findMany.totalPages = 1;
@@ -539,25 +531,32 @@ const mediaSosial = proxy({
image: true; image: true;
}; };
}> | null, }> | null,
loading: false,
async load(id: string) { async load(id: string) {
if (!id) { if (!id) {
toast.warn("ID tidak valid"); toast.warn("ID tidak valid");
return null; return null;
} }
mediaSosial.update.loading = true; mediaSosial.findUnique.loading = true;
try { try {
const res = await fetch(`/api/landingpage/mediasosial/${id}`); const res = await (ApiFetch.api.landingpage.mediasosial as any)[id].get();
if (res.ok) { if (res.data?.success) {
const data = await res.json(); mediaSosial.findUnique.data = res.data.data ?? null;
mediaSosial.findUnique.data = data.data ?? null; return res.data.data;
} else { } else {
console.error("Failed to fetch media sosial:", res.statusText); toast.error(res.data?.message || "Gagal memuat data media sosial");
mediaSosial.findUnique.data = null; mediaSosial.findUnique.data = null;
return null;
} }
} catch (error) { } catch (error) {
console.error("Error fetching media sosial:", error); if (process.env.NODE_ENV === 'development') {
console.error("Error fetching media sosial:", error);
}
mediaSosial.findUnique.data = null; mediaSosial.findUnique.data = null;
return null;
} finally {
mediaSosial.findUnique.loading = false;
} }
}, },
}, },
@@ -568,24 +567,18 @@ const mediaSosial = proxy({
try { try {
mediaSosial.delete.loading = true; mediaSosial.delete.loading = true;
const res = await (ApiFetch.api.landingpage.mediasosial as any)["del"][id].delete();
const response = await fetch(`/api/landingpage/mediasosial/del/${id}`, { if (res.data?.success) {
method: "DELETE", toast.success(res.data.message || "Media Sosial berhasil dihapus");
headers: { await mediaSosial.findMany.load();
"Content-Type": "application/json",
},
});
const result = await response.json();
if (response.ok && result?.success) {
toast.success(result.message || "Media Sosial berhasil dihapus");
await mediaSosial.findMany.load(); // refresh list
} else { } else {
toast.error(result?.message || "Gagal menghapus media sosial"); toast.error(res.data?.message || "Gagal menghapus media sosial");
} }
} catch (error) { } catch (error) {
console.error("Gagal delete:", error); if (process.env.NODE_ENV === 'development') {
console.error("Gagal delete:", error);
}
toast.error("Terjadi kesalahan saat menghapus media sosial"); toast.error("Terjadi kesalahan saat menghapus media sosial");
} finally { } finally {
mediaSosial.delete.loading = false; mediaSosial.delete.loading = false;
@@ -603,43 +596,32 @@ const mediaSosial = proxy({
return null; return null;
} }
mediaSosial.update.loading = true; // ✅ Tambahkan ini di awal mediaSosial.update.loading = true;
try { try {
const response = await fetch(`/api/landingpage/mediasosial/${id}`, { const res = await (ApiFetch.api.landingpage.mediasosial as any)[id].get();
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) { if (res.data?.success) {
throw new Error(`HTTP error! status: ${response.status}`); const data = res.data.data;
}
const result = await response.json();
if (result?.success) {
const data = result.data;
this.id = data.id; this.id = data.id;
this.form = { this.form = {
name: data.name || "", name: data.name || "",
imageId: data.imageId || null, imageId: data.imageId || null,
iconUrl: data.iconUrl || "", iconUrl: data.iconUrl || "",
icon: data.icon || null, icon: data.icon || null,
}; };
return data; return data;
} else { } else {
throw new Error( toast.error(res.data?.message || "Gagal mengambil data media sosial");
result?.message || "Gagal mengambil data media sosial" return null;
);
} }
} catch (error) { } catch (error) {
console.error((error as Error).message); if (process.env.NODE_ENV === 'development') {
console.error("Error loading media sosial:", error);
}
toast.error("Terjadi kesalahan saat mengambil data media sosial"); toast.error("Terjadi kesalahan saat mengambil data media sosial");
return null;
} finally { } finally {
mediaSosial.update.loading = false; // ✅ Supaya berhenti loading walau error mediaSosial.update.loading = false;
} }
}, },
@@ -655,41 +637,25 @@ const mediaSosial = proxy({
try { try {
mediaSosial.update.loading = true; mediaSosial.update.loading = true;
const res = await (ApiFetch.api.landingpage.mediasosial as any)[this.id].put({
name: this.form.name,
imageId: this.form.imageId,
iconUrl: this.form.iconUrl,
icon: this.form.icon,
});
const response = await fetch( if (res.data?.success) {
`/api/landingpage/mediasosial/${this.id}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: this.form.name,
imageId: this.form.imageId,
iconUrl: this.form.iconUrl,
icon: this.form.icon,
}),
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message || `HTTP error! status: ${response.status}`
);
}
const result = await response.json();
if (result.success) {
toast.success("Berhasil update media sosial"); toast.success("Berhasil update media sosial");
await mediaSosial.findMany.load(); // refresh list await mediaSosial.findMany.load();
return true; return true;
} else { } else {
throw new Error(result.message || "Gagal update media sosial"); toast.error(res.data?.message || "Gagal update media sosial");
return false;
} }
} catch (error) { } catch (error) {
console.error("Error updating media sosial:", error); if (process.env.NODE_ENV === 'development') {
console.error("Error updating media sosial:", error);
}
toast.error( toast.error(
error instanceof Error error instanceof Error
? error.message ? error.message

View File

@@ -8,6 +8,7 @@ import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import DOMPurify from 'dompurify';
function DetailProgramInovasi() { function DetailProgramInovasi() {
const stateProgramInovasi = useProxy(profileLandingPageState.programInovasi) const stateProgramInovasi = useProxy(profileLandingPageState.programInovasi)
@@ -85,7 +86,7 @@ function DetailProgramInovasi() {
<Box> <Box>
<Text fz="lg" fw="bold">Deskripsi</Text> <Text fz="lg" fw="bold">Deskripsi</Text>
<Box pl={5}> <Box pl={5}>
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.description || '-' }}></Text> <Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(data.description || '-') }}></Text>
</Box> </Box>
</Box> </Box>

View File

@@ -6,6 +6,7 @@ import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import DOMPurify from 'dompurify';
import HeaderSearch from '../../../_com/header'; import HeaderSearch from '../../../_com/header';
import profileLandingPageState from '../../../_state/landing-page/profile'; import profileLandingPageState from '../../../_state/landing-page/profile';
@@ -90,7 +91,7 @@ function ListProgramInovasi({ search }: { search: string }) {
<Text fw={500}>{item.name}</Text> <Text fw={500}>{item.name}</Text>
</TableTd> </TableTd>
<TableTd style={{ maxWidth: 250 }}> <TableTd style={{ maxWidth: 250 }}>
<Text fz="sm" lineClamp={1} dangerouslySetInnerHTML={{ __html: item.description || '-' }}></Text> <Text fz="sm" lineClamp={1} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(item.description || '-') }}></Text>
</TableTd> </TableTd>
<TableTd style={{ maxWidth: 250 }}> <TableTd style={{ maxWidth: 250 }}>
<Tooltip label="Buka tautan program" position="top" withArrow> <Tooltip label="Buka tautan program" position="top" withArrow>
@@ -144,7 +145,7 @@ function ListProgramInovasi({ search }: { search: string }) {
{/* Description */} {/* Description */}
<Box> <Box>
<Text fz="sm" fw={600} lh={1.4}>Deskripsi</Text> <Text fz="sm" fw={600} lh={1.4}>Deskripsi</Text>
<Text dangerouslySetInnerHTML={{ __html: item.description || '-' }} fz="sm" c="gray.7" lineClamp={2} /> <Text dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(item.description || '-') }} fz="sm" c="gray.7" lineClamp={2} />
</Box> </Box>
{/* Link */} {/* Link */}