diff --git a/api/auth.ts b/api/auth.ts index dba9aff..864ae59 100644 --- a/api/auth.ts +++ b/api/auth.ts @@ -113,30 +113,61 @@ export async function seedLocalUserIfMissing() { // Check existence without selecting `password_hash` to avoid failures on older schemas try { const [rows] = await db.execute(sql`SELECT id FROM users WHERE unionId = ${LocalAuth.defaultAdminEmail} LIMIT 1`); - if (Array.isArray(rows) && rows.length > 0) return; } catch (err) { console.warn('[seed] existence check failed, will attempt to insert anyway', err); } - // Try to insert using the typed Drizzle insert (preferred). If that fails because the - // `password_hash` column is missing, fall back to a raw INSERT that omits the column. + // 1. Ensure admin account try { - await db.insert(users).values({ - unionId: LocalAuth.defaultAdminEmail, - name: "Admin Lokal", - email: LocalAuth.defaultAdminEmail, - passwordHash: hashPassword(LocalAuth.defaultAdminPassword), - role: "admin", - lastSignInAt: new Date(), - }); + const [existingAdmin] = await db.execute(sql`SELECT id FROM users WHERE unionId = ${LocalAuth.defaultAdminEmail} LIMIT 1`); + if (!existingAdmin || (Array.isArray(existingAdmin) && existingAdmin.length === 0)) { + await db.insert(users).values({ + unionId: LocalAuth.defaultAdminEmail, + name: "Admin Lokal", + email: LocalAuth.defaultAdminEmail, + passwordHash: hashPassword(LocalAuth.defaultAdminPassword), + role: "admin", + lastSignInAt: new Date(), + }); + console.log(`[seed] Created admin account: ${LocalAuth.defaultAdminEmail}`); + } } catch (err) { - console.warn('[seed] typed insert failed, attempting fallback insert without password_hash', err); + console.warn('[seed] Admin seeding failed, attempting fallback', err); try { - await db.execute(sql`INSERT INTO users (unionId, name, email, role, lastSignInAt) VALUES ( + await db.execute(sql`INSERT IGNORE INTO users (unionId, name, email, role, lastSignInAt) VALUES ( ${LocalAuth.defaultAdminEmail}, ${'Admin Lokal'}, ${LocalAuth.defaultAdminEmail}, ${'admin'}, ${new Date()} )`); } catch (err2) { - console.error('[seed] fallback insert also failed', err2); + console.error('[seed] Admin fallback failed', err2); + } + } + + // 2. Seed default roles for testing + const defaultAccounts = [ + { email: "officer@example.com", name: "Petugas Lapangan", role: "officer" as const }, + { email: "manager@example.com", name: "Pengelola Wilayah", role: "manager" as const }, + ]; + + for (const acc of defaultAccounts) { + try { + const res = await db.execute(sql`SELECT id FROM users WHERE unionId = ${acc.email} LIMIT 1`); + const [existing] = res as any; + + if (existing && (!Array.isArray(existing) || existing.length > 0)) { + continue; + } + + await db.insert(users).values({ + unionId: acc.email, + name: acc.name, + email: acc.email, + passwordHash: hashPassword("password123"), + role: acc.role, + lastSignInAt: new Date(), + }); + console.log(`[seed] Created ${acc.role} account: ${acc.email}`); + } catch (err) { + console.warn(`[seed] Failed to create ${acc.role} (${acc.email})`, err); } } } \ No newline at end of file diff --git a/scratch/check_users.ts b/scratch/check_users.ts new file mode 100644 index 0000000..c7b2ccf --- /dev/null +++ b/scratch/check_users.ts @@ -0,0 +1,16 @@ +import { sql } from "drizzle-orm"; +import { getDb } from "../api/queries/connection"; +import { users } from "../db/schema"; + +async function check() { + const db = getDb(); + const res = await db.execute(sql`SELECT id FROM users WHERE unionId = 'officer@example.com' LIMIT 1`); + console.log("Raw query result for officer:", res); + + const allUsers = await db.select().from(users); + console.log("Current Users in DB:"); + console.table(allUsers.map(u => ({ id: u.id, email: u.email, role: u.role }))); + process.exit(0); +} + +check().catch(console.error); diff --git a/scratch/force_seed.ts b/scratch/force_seed.ts new file mode 100644 index 0000000..f608604 --- /dev/null +++ b/scratch/force_seed.ts @@ -0,0 +1,10 @@ +import { seedLocalUserIfMissing } from "../api/auth"; + +async function forceSeed() { + console.log("Starting force seed..."); + await seedLocalUserIfMissing(); + console.log("Force seed completed."); + process.exit(0); +} + +forceSeed().catch(console.error); diff --git a/src/App.tsx b/src/App.tsx index d58724f..528a0ca 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,7 +11,13 @@ import VerificationPage from "./pages/VerificationPage"; import UsersPage from "./pages/UsersPage"; import SettingsPage from "./pages/SettingsPage"; -function ProtectedRoute({ children, adminOnly }: { children: React.ReactNode; adminOnly?: boolean }) { +function ProtectedRoute({ + children, + allowedRoles, +}: { + children: React.ReactNode; + allowedRoles?: string[]; +}) { const { user, isLoading } = useAuth(); if (isLoading) { @@ -26,7 +32,7 @@ function ProtectedRoute({ children, adminOnly }: { children: React.ReactNode; ad return ; } - if (adminOnly && user.role !== "admin") { + if (allowedRoles && !allowedRoles.includes(user.role)) { return ; } @@ -72,7 +78,7 @@ export default function App() { + } @@ -80,7 +86,7 @@ export default function App() { + } @@ -88,7 +94,7 @@ export default function App() { + } diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 9fca0ee..9f460ca 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -33,9 +33,14 @@ const menuItems = [ { icon: Map, label: "Peta GIS", path: "/map" }, { icon: Users, label: "Penerima Bantuan", path: "/recipients" }, { icon: MapPinHouse, label: "Rumah Ibadah", path: "/places" }, - { icon: ClipboardCheck, label: "Verifikasi", path: "/verification" }, - { icon: Shield, label: "Pengguna", path: "/users", adminOnly: true }, - { icon: Settings, label: "Pengaturan", path: "/settings" }, + { + icon: ClipboardCheck, + label: "Verifikasi", + path: "/verification", + roles: ["admin", "officer"], + }, + { icon: Shield, label: "Pengguna", path: "/users", roles: ["admin"] }, + { icon: Settings, label: "Pengaturan", path: "/settings", roles: ["admin"] }, ]; export default function AppLayout({ children }: { children: ReactNode }) { @@ -46,11 +51,10 @@ export default function AppLayout({ children }: { children: ReactNode }) { const [collapsed, setCollapsed] = useState(false); const [mobileOpen, setMobileOpen] = useState(false); - const isAdmin = user?.role === "admin"; const activeItem = menuItems.find((item) => item.path === location.pathname); const filteredItems = menuItems.filter( - (item) => !item.adminOnly || isAdmin + (item) => !item.roles || (user?.role && item.roles.includes(user.role)) ); const sidebarWidth = collapsed ? 72 : 260; @@ -95,11 +99,6 @@ export default function AppLayout({ children }: { children: ReactNode }) { > {!collapsed && {item.label}} - {!collapsed && item.adminOnly && ( - - Admin - - )} ); })} diff --git a/src/pages/PlacesOfWorshipPage.tsx b/src/pages/PlacesOfWorshipPage.tsx index d7601a7..73f8ec2 100644 --- a/src/pages/PlacesOfWorshipPage.tsx +++ b/src/pages/PlacesOfWorshipPage.tsx @@ -59,6 +59,12 @@ function parseRupiahInput(input: string) { } export default function PlacesOfWorshipPage() { + const { user } = useAuth(); + const isAdmin = user?.role === "admin"; + const isManager = user?.role === "manager"; + const canModify = isAdmin; // Only Admin can modify Places of Worship + const canDistribute = isAdmin || isManager; // Admin and Manager can distribute aid + const [search, setSearch] = useState(""); const [typeFilter, setTypeFilter] = useState("all"); const [isAddOpen, setIsAddOpen] = useState(false); @@ -243,79 +249,81 @@ export default function PlacesOfWorshipPage() {

Rumah Ibadah

Kelola data rumah ibadah dan cakupan wilayahnya

- - - - - - Tambah Rumah Ibadah -
-
- - setFormData({ ...formData, name: e.target.value })} placeholder="Tulis nama rumah ibadah" className="h-9" /> -
-
-
- - -
-
- - setFormData({ ...formData, capacity: parseInt(e.target.value) || 1 })} className="h-9" /> -
-
-
- - setFormData({ ...formData, address: e.target.value })} placeholder="Alamat lengkap" className="h-9" /> -
- -
-
- - setFormData({ ...formData, latitude: e.target.value })} placeholder="-6.xxxx" className="h-9" /> -
-
- - setFormData({ ...formData, longitude: e.target.value })} placeholder="106.xxxx" className="h-9" /> -
-
-
-
- - setFormData({ ...formData, radius: parseInt(e.target.value) || 1000 })} className="h-9" /> -
-
- - setFormData({ ...formData, contactPhone: e.target.value })} placeholder="08xxxxxxxxxx" className="h-9" /> -
-
-
- - setFormData({ ...formData, contactName: e.target.value })} placeholder="Nama penanggung jawab" className="h-9" /> -
- - -
-
+ + + Tambah Rumah Ibadah +
+
+ + setFormData({ ...formData, name: e.target.value })} placeholder="Tulis nama rumah ibadah" className="h-9" /> +
+
+
+ + +
+
+ + setFormData({ ...formData, capacity: parseInt(e.target.value) || 1 })} className="h-9" /> +
+
+
+ + setFormData({ ...formData, address: e.target.value })} placeholder="Alamat lengkap" className="h-9" /> +
+ +
+
+ + setFormData({ ...formData, latitude: e.target.value })} placeholder="-6.xxxx" className="h-9" /> +
+
+ + setFormData({ ...formData, longitude: e.target.value })} placeholder="106.xxxx" className="h-9" /> +
+
+
+
+ + setFormData({ ...formData, radius: parseInt(e.target.value) || 1000 })} className="h-9" /> +
+
+ + setFormData({ ...formData, contactPhone: e.target.value })} placeholder="08xxxxxxxxxx" className="h-9" /> +
+
+
+ + setFormData({ ...formData, contactName: e.target.value })} placeholder="Nama penanggung jawab" className="h-9" /> +
+ + +
+ + )} {/* Filters */} @@ -385,15 +393,21 @@ export default function PlacesOfWorshipPage() { - - - + {canModify && ( + + )} + {canDistribute && ( + + )} + {canModify && ( + + )} diff --git a/src/pages/RecipientsPage.tsx b/src/pages/RecipientsPage.tsx index 06ddc44..e72810a 100644 --- a/src/pages/RecipientsPage.tsx +++ b/src/pages/RecipientsPage.tsx @@ -78,6 +78,11 @@ function parseRupiahInput(input: string) { } export default function RecipientsPage() { + const { user } = useAuth(); + const isAdmin = user?.role === "admin"; + const isManager = user?.role === "manager"; + const canModify = isAdmin || isManager; + const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); const [isAddOpen, setIsAddOpen] = useState(false); @@ -308,120 +313,122 @@ export default function RecipientsPage() { Kelola data warga penerima bantuan sosial

- - - - - - - Tambah Penerima Baru - -
-
-
- - setFormData({ ...formData, nik: e.target.value })} placeholder="16 digit" maxLength={16} className="h-9" /> -
-
- - setFormData({ ...formData, name: e.target.value })} placeholder="Nama" className="h-9" /> -
-
-
-
- - setFormData({ ...formData, birthDate: e.target.value })} className="h-9" /> -
-
- - -
-
-
- - updateFormData({ address: e.target.value })} placeholder="Alamat lengkap" className="h-9" /> -
- -
- updateFormData({ ktpDocument: value ?? "" })} - required - helperText="JPG, PNG, PDF - Maks 5MB" - /> -
- updateFormData({ kkDocument: value ?? "" })} - required - helperText="JPG, PNG, PDF" - /> - updateFormData({ sktmDocument: value ?? "" })} - helperText="JPG, PNG, PDF (Opsional)" - /> -
-
-
-
- - updateFormData({ phone: e.target.value })} placeholder="08xxxxxxxxxx" className="h-9" /> -
-
- - updateFormData({ familyMembers: parseInt(e.target.value) || 1 })} className="h-9" /> -
-
-
-
- - updateFormData({ incomePerMonth: parseRupiahInput(e.target.value) })} - className="h-9" - /> -
-
-
-

Rumah ibadah terdekat (otomatis)

-

- {nearestPlaceForForm - ? `${nearestPlaceForForm.name} • ${formatDistance(nearestPlaceForForm.distanceKm)}` - : "Pilih titik pada peta untuk menentukan rumah ibadah terdekat."} -

-
-
- - updateFormData({ notes: e.target.value })} placeholder="Catatan tambahan" className="h-9" /> -
- - -
-
+ + + + Tambah Penerima Baru + +
+
+
+ + setFormData({ ...formData, nik: e.target.value })} placeholder="16 digit" maxLength={16} className="h-9" /> +
+
+ + setFormData({ ...formData, name: e.target.value })} placeholder="Nama" className="h-9" /> +
+
+
+
+ + setFormData({ ...formData, birthDate: e.target.value })} className="h-9" /> +
+
+ + +
+
+
+ + updateFormData({ address: e.target.value })} placeholder="Alamat lengkap" className="h-9" /> +
+ +
+ updateFormData({ ktpDocument: value ?? "" })} + required + helperText="JPG, PNG, PDF - Maks 5MB" + /> +
+ updateFormData({ kkDocument: value ?? "" })} + required + helperText="JPG, PNG, PDF" + /> + updateFormData({ sktmDocument: value ?? "" })} + helperText="JPG, PNG, PDF (Opsional)" + /> +
+
+
+
+ + updateFormData({ phone: e.target.value })} placeholder="08xxxxxxxxxx" className="h-9" /> +
+
+ + updateFormData({ familyMembers: parseInt(e.target.value) || 1 })} className="h-9" /> +
+
+
+
+ + updateFormData({ incomePerMonth: parseRupiahInput(e.target.value) })} + className="h-9" + /> +
+
+
+

Rumah ibadah terdekat (otomatis)

+

+ {nearestPlaceForForm + ? `${nearestPlaceForForm.name} • ${formatDistance(nearestPlaceForForm.distanceKm)}` + : "Pilih titik pada peta untuk menentukan rumah ibadah terdekat."} +

+
+
+ + updateFormData({ notes: e.target.value })} placeholder="Catatan tambahan" className="h-9" /> +
+ + +
+ + )} {/* Filters */} @@ -512,26 +519,30 @@ export default function RecipientsPage() { > - - + {canModify && ( + <> + + + + )}