Compare commits

...

3 Commits

Author SHA1 Message Date
745cbf9f2f feat: redirect home to dashboard untuk user yang sudah login
- Update authMiddleware: dashboard untuk semua user, admin hanya untuk admin
- Tambah redirect otomatis dari / ke /dashboard jika sudah login
- User biasa sekarang bisa akses halaman dashboard
- Admin panel tetap terbatas untuk role admin saja

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-12 15:38:30 +08:00
918399bf62 Fix Gambar 2026-03-12 15:16:41 +08:00
1ec10fe623 Fix seed-2 2026-02-26 16:30:22 +08:00
8 changed files with 260 additions and 204 deletions

View File

@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "bun run gen:api && REACT_EDITOR=antigravity bun --hot src/index.ts",
"dev": "lsof -ti:3000 | xargs kill -9 2>/dev/null || true; bun run gen:api && REACT_EDITOR=antigravity bun --hot src/index.ts",
"lint": "biome check .",
"check": "biome check --write .",
"format": "biome format --write .",
@@ -12,7 +12,7 @@
"test": "bun test __tests__/api",
"test:ui": "bun test --ui __tests__/api",
"test:e2e": "bun run build && playwright test",
"build": "bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='VITE_*'",
"build": "bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='VITE_*' && cp -r public/* dist/ 2>/dev/null || true",
"start": "NODE_ENV=production bun src/index.ts",
"seed": "bun prisma/seed.ts"
},

View File

@@ -133,7 +133,18 @@ async function main() {
console.log("Database seeding completed.");
}
main().catch((error) => {
console.error("Error during seeding:", error);
process.exit(1);
});
// Only auto-execute when run directly (not when imported)
const isMainModule =
typeof require !== "undefined"
? require.main === module
: import.meta.path.endsWith("seed.ts");
if (isMainModule) {
main().catch((error) => {
console.error("Error during seeding:", error);
process.exit(1);
});
}
// Export for programmatic use
export { seedAdminUser, seedDemoUsers, main as runSeed };

BIN
public/logo-desa-plus.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,13 +1,11 @@
import {
Badge,
Box,
Collapse,
Group,
Image,
Input,
NavLink as MantineNavLink,
Stack,
Text,
useMantineColorScheme,
useMantineColorScheme
} from "@mantine/core";
import { useLocation, useNavigate } from "@tanstack/react-router";
import { ChevronDown, ChevronUp, Search } from "lucide-react";
@@ -66,30 +64,7 @@ export function Sidebar({ className }: SidebarProps) {
return (
<Box className={className}>
{/* Logo */}
<Box
p="md"
style={{ borderBottom: "1px solid var(--mantine-color-gray-3)" }}
>
<Group gap="xs">
<Badge
color="dark"
variant="filled"
size="xl"
radius="md"
py="xs"
px="md"
style={{ fontSize: "1.5rem", fontWeight: "bold" }}
>
DESA
</Badge>
<Badge color="green" variant="filled" size="md" radius="md">
+
</Badge>
</Group>
<Text size="xs" c="dimmed" mt="xs">
Digitalisasi Desa Transparansi Kerja
</Text>
</Box>
<Image src={"/logo-desa-plus.png"} width={201} height={84} />
{/* Search */}
<Box p="md">

View File

@@ -10,201 +10,242 @@ const PORT = process.env.PORT || 3000;
const isProduction = process.env.NODE_ENV === "production";
// Auto-seed database in production (ensure admin user exists)
if (isProduction && process.env.ADMIN_EMAIL) {
try {
console.log("🌱 Running database seed in production...");
const { runSeed } = await import("../prisma/seed.ts");
await runSeed();
} catch (error) {
console.error("⚠️ Production seed failed:", error);
// Don't crash the server if seed fails
}
}
const app = new Elysia().use(api);
if (!isProduction) {
// Development: Use Vite middleware
const { createVite } = await import("./vite");
const vite = await createVite();
// Development: Use Vite middleware
const { createVite } = await import("./vite");
const vite = await createVite();
// Serve PWA/TWA assets in dev (root and nested path support)
const _servePwaAsset = (srcPath: string) => () => Bun.file(srcPath);
// Serve PWA/TWA assets in dev (root and nested path support)
const _servePwaAsset = (srcPath: string) => () => Bun.file(srcPath);
app.post("/__open-in-editor", ({ body }) => {
const { relativePath, lineNumber, columnNumber } = body as {
relativePath: string;
lineNumber: number;
columnNumber: number;
};
app.post("/__open-in-editor", ({ body }) => {
const { relativePath, lineNumber, columnNumber } = body as {
relativePath: string;
lineNumber: number;
columnNumber: number;
};
openInEditor(relativePath, {
line: lineNumber,
column: columnNumber,
editor: "antigravity",
});
openInEditor(relativePath, {
line: lineNumber,
column: columnNumber,
editor: "antigravity",
});
return { ok: true };
});
return { ok: true };
});
// Vite middleware for other requests
app.all("*", async ({ request }) => {
const url = new URL(request.url);
const pathname = url.pathname;
// Vite middleware for other requests
app.all("*", async ({ request }) => {
const url = new URL(request.url);
const pathname = url.pathname;
// Serve transformed index.html for root or any path that should be handled by the SPA
if (
pathname === "/" ||
(!pathname.includes(".") &&
!pathname.startsWith("/@") &&
!pathname.startsWith("/inspector") &&
!pathname.startsWith("/__open-stack-frame-in-editor") &&
!pathname.startsWith("/api"))
) {
try {
const htmlPath = path.resolve("src/index.html");
let html = fs.readFileSync(htmlPath, "utf-8");
html = await vite.transformIndexHtml(pathname, html);
// Serve transformed index.html for root or any path that should be handled by the SPA
if (
pathname === "/" ||
(!pathname.includes(".") &&
!pathname.startsWith("/@") &&
!pathname.startsWith("/inspector") &&
!pathname.startsWith("/__open-stack-frame-in-editor") &&
!pathname.startsWith("/api"))
) {
try {
const htmlPath = path.resolve("src/index.html");
let html = fs.readFileSync(htmlPath, "utf-8");
html = await vite.transformIndexHtml(pathname, html);
return new Response(html, {
headers: { "Content-Type": "text/html" },
});
} catch (e) {
console.error(e);
}
}
return new Response(html, {
headers: { "Content-Type": "text/html" },
});
} catch (e) {
console.error(e);
}
}
return new Promise<Response>((resolve) => {
// Use a Proxy to mock Node.js req because Bun's Request is read-only
const req = new Proxy(request, {
get(target, prop) {
if (prop === "url") return pathname + url.search;
if (prop === "method") return request.method;
if (prop === "headers")
return Object.fromEntries(request.headers as any);
return (target as any)[prop];
},
}) as any;
return new Promise<Response>((resolve) => {
// Use a Proxy to mock Node.js req because Bun's Request is read-only
const req = new Proxy(request, {
get(target, prop) {
if (prop === "url") return pathname + url.search;
if (prop === "method") return request.method;
if (prop === "headers")
return Object.fromEntries(request.headers as any);
return (target as any)[prop];
},
}) as any;
const res = {
statusCode: 200,
setHeader(name: string, value: string) {
this.headers[name.toLowerCase()] = value;
},
getHeader(name: string) {
return this.headers[name.toLowerCase()];
},
headers: {} as Record<string, string>,
end(data: any) {
// Handle potential Buffer or string data from Vite
let body = data;
if (data instanceof Uint8Array) {
body = data;
} else if (typeof data === "string") {
body = data;
} else if (data) {
body = String(data);
}
const res = {
statusCode: 200,
setHeader(name: string, value: string) {
this.headers[name.toLowerCase()] = value;
},
getHeader(name: string) {
return this.headers[name.toLowerCase()];
},
writeHead(code: number, headers: Record<string, string>) {
this.statusCode = code;
Object.assign(this.headers, headers);
},
write(chunk: any, callback?: () => void) {
// Collect chunks for streaming responses
if (!this._chunks) this._chunks = [];
this._chunks.push(chunk);
if (callback) callback();
return true; // Indicate we can accept more data
},
headers: {} as Record<string, string>,
end(data: any) {
// Handle potential Buffer or string data from Vite
let body = data;
// If we have collected chunks from write() calls, combine them
if (this._chunks && this._chunks.length > 0) {
body = Buffer.concat(this._chunks);
}
if (data instanceof Uint8Array) {
body = data;
} else if (typeof data === "string") {
body = data;
} else if (data) {
body = String(data);
}
resolve(
new Response(body || "", {
status: this.statusCode,
headers: this.headers,
}),
);
},
// Minimal event emitter mock
once() {
return this;
},
on() {
return this;
},
emit() {
return this;
},
removeListener() {
return this;
},
} as any;
resolve(
new Response(body || "", {
status: this.statusCode,
headers: this.headers,
}),
);
},
// Minimal event emitter mock
once() {
return this;
},
on() {
return this;
},
emit() {
return this;
},
removeListener() {
return this;
},
} as any;
vite.middlewares(req, res, (err: any) => {
if (err) {
console.error("Vite middleware error:", err);
resolve(new Response(err.stack || err.toString(), { status: 500 }));
return;
}
// If Vite doesn't handle it, return 404
resolve(new Response("Not Found", { status: 404 }));
});
});
});
vite.middlewares(req, res, (err: any) => {
if (err) {
console.error("Vite middleware error:", err);
resolve(new Response(err.stack || err.toString(), { status: 500 }));
return;
}
// If Vite doesn't handle it, return 404
resolve(new Response("Not Found", { status: 404 }));
});
});
});
} else {
// Production: Final catch-all for static files and SPA fallback
app.all("*", async ({ request }) => {
const url = new URL(request.url);
const pathname = url.pathname;
// Production: Final catch-all for static files and SPA fallback
app.all("*", async ({ request }) => {
const url = new URL(request.url);
const pathname = url.pathname;
// 1. Try exact match in dist
let filePath = path.join(
"dist",
pathname === "/" ? "index.html" : pathname,
);
// 1. Try exact match in dist
let filePath = path.join(
"dist",
pathname === "/" ? "index.html" : pathname,
);
// 1.1 Special handling for PWA/TWA assets that might not be in dist (since we use custom bun build)
if (isProduction) {
const srcPath = path.join("src", pathname);
if (fs.existsSync(srcPath)) {
filePath = srcPath;
}
}
// 1.1 Special handling for PWA/TWA assets that might not be in dist (since we use custom bun build)
if (isProduction) {
const srcPath = path.join("src", pathname);
if (fs.existsSync(srcPath)) {
filePath = srcPath;
}
// Check public folder for static assets
const publicPath = path.join("public", pathname);
if (fs.existsSync(publicPath)) {
filePath = publicPath;
}
}
// 2. If not found and looks like an asset (has extension), try root of dist or src
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
if (pathname.includes(".") && !pathname.endsWith("/")) {
const filename = path.basename(pathname);
// 2. If not found and looks like an asset (has extension), try root of dist or src
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
if (pathname.includes(".") && !pathname.endsWith("/")) {
const filename = path.basename(pathname);
// Try root of dist
const fallbackDistPath = path.join("dist", filename);
if (
fs.existsSync(fallbackDistPath) &&
fs.statSync(fallbackDistPath).isFile()
) {
filePath = fallbackDistPath;
}
// Special handling for PWA files in src
else if (pathname.includes("assetlinks.json")) {
const srcFilename = pathname.includes("assetlinks.json")
? ".well-known/assetlinks.json"
: filename;
const fallbackSrcPath = path.join("src", srcFilename);
if (
fs.existsSync(fallbackSrcPath) &&
fs.statSync(fallbackSrcPath).isFile()
) {
filePath = fallbackSrcPath;
}
}
}
}
// Try root of dist
const fallbackDistPath = path.join("dist", filename);
if (
fs.existsSync(fallbackDistPath) &&
fs.statSync(fallbackDistPath).isFile()
) {
filePath = fallbackDistPath;
}
// Try public folder
else {
const fallbackPublicPath = path.join("public", filename);
if (
fs.existsSync(fallbackPublicPath) &&
fs.statSync(fallbackPublicPath).isFile()
) {
filePath = fallbackPublicPath;
}
}
// Special handling for PWA files in src
if (pathname.includes("assetlinks.json")) {
const srcFilename = pathname.includes("assetlinks.json")
? ".well-known/assetlinks.json"
: filename;
const fallbackSrcPath = path.join("src", srcFilename);
if (
fs.existsSync(fallbackSrcPath) &&
fs.statSync(fallbackSrcPath).isFile()
) {
filePath = fallbackSrcPath;
}
}
}
}
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
const file = Bun.file(filePath);
return new Response(file, {
headers: {
Vary: "Accept-Encoding",
},
});
}
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
const file = Bun.file(filePath);
return new Response(file, {
headers: {
Vary: "Accept-Encoding",
},
});
}
// 3. SPA Fallback: Serve index.html
const indexHtml = path.join("dist", "index.html");
if (fs.existsSync(indexHtml)) {
return new Response(Bun.file(indexHtml), {
headers: {
Vary: "Accept-Encoding",
},
});
}
// 3. SPA Fallback: Serve index.html
const indexHtml = path.join("dist", "index.html");
if (fs.existsSync(indexHtml)) {
return new Response(Bun.file(indexHtml), {
headers: {
Vary: "Accept-Encoding",
},
});
}
return new Response("Not Found", { status: 404 });
});
return new Response("Not Found", { status: 404 });
});
}
app.listen(PORT);
console.log(
`🚀 Server running at http://localhost:${PORT} in ${isProduction ? "production" : "development"} mode`,
`🚀 Server running at http://localhost:${PORT} in ${isProduction ? "production" : "development"} mode`,
);
export type ApiApp = typeof app;

View File

@@ -68,8 +68,18 @@ const routeRules: RouteRule[] = [
{
match: (p) => p === "/dashboard" || p.startsWith("/dashboard/"),
requireAuth: true,
redirectTo: "/signin",
},
{
match: (p) => p === "/admin" || p.startsWith("/admin/"),
requireAuth: true,
requiredRole: "admin",
redirectTo: "/profile",
redirectTo: "/dashboard",
},
{
match: (p) => p === "/" || p === "",
requireAuth: false,
redirectTo: "/dashboard",
},
];

View File

@@ -32,11 +32,29 @@ import {
IconStack2,
IconSun,
} from "@tabler/icons-react";
import { createFileRoute, Link } from "@tanstack/react-router";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { useSnapshot } from "valtio";
import { authStore } from "@/store/auth";
export const Route = createFileRoute("/")({
component: HomePage,
beforeLoad: async () => {
// Check if user is already logged in
const session = await fetch("/api/session", {
method: "GET",
credentials: "include",
}).then((res) => (res.ok ? res.json() : null));
// If user is logged in, redirect to dashboard
if (session?.data?.user) {
throw {
redirect: {
to: "/dashboard",
},
};
}
},
});
// Navigation items

View File

@@ -8,6 +8,7 @@ import { createServer as createViteServer } from "vite";
export async function createVite() {
return createViteServer({
root: process.cwd(),
publicDir: "public",
resolve: {
alias: {
"@": path.resolve(process.cwd(), "./src"),