diff --git a/deploy/openrc/abelbirdnest-web b/deploy/openrc/abelbirdnest-web new file mode 100644 index 0000000..256b7e6 --- /dev/null +++ b/deploy/openrc/abelbirdnest-web @@ -0,0 +1,24 @@ +#!/sbin/openrc-run + +name="AbelBirdnest Stock" +description="AbelBirdnest Stock Next.js standalone server" + +directory="/var/www/abelbirdnest-web/AbelBirdNest-Stock/current" +env_file="/var/www/abelbirdnest-web/AbelBirdNest-Stock/.env.production" +command="/bin/sh" +command_args="-lc 'set -a; . \"${env_file}\"; set +a; exec /usr/bin/node server.js'" +command_user="abelbirdnest:abelbirdnest" +command_background="yes" +pidfile="/run/${RC_SVCNAME}.pid" + +output_log="/var/log/abelbirdnest-web.log" +error_log="/var/log/abelbirdnest-web.err" + +export NODE_ENV="production" +export PORT="3007" +export NODE_OPTIONS="--max-old-space-size=512" + +depend() { + need net + after postgresql +} diff --git a/deploy/scripts/build-linux-release.sh b/deploy/scripts/build-linux-release.sh index 94ce0de..9b61d11 100755 --- a/deploy/scripts/build-linux-release.sh +++ b/deploy/scripts/build-linux-release.sh @@ -2,21 +2,26 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -IMAGE="${DOCKER_IMAGE:-node:20-bookworm-slim}" -ARTIFACT_NAME="${ARTIFACT_NAME:-abelbirdnest-release.tar.gz}" +IMAGE="${DOCKER_IMAGE:-node:20-alpine}" +RELEASE_STAMP="${RELEASE_STAMP:-$(date +%Y%m%d-%H%M%S)}" +ARTIFACT_NAME="${ARTIFACT_NAME:-abelbirdnest-release-${RELEASE_STAMP}.tar.gz}" NODE_MEMORY_MB="${NODE_MEMORY_MB:-4096}" cd "$ROOT_DIR" +if ! command -v docker >/dev/null 2>&1; then + echo "Docker is required to build the Alpine Linux release artifact." >&2 + exit 1 +fi + docker run --rm \ -v "$ROOT_DIR":/src:ro \ -v "$ROOT_DIR":/out \ -w / \ "$IMAGE" \ - bash -lc ' - set -euo pipefail - apt-get update >/dev/null - apt-get install -y --no-install-recommends openssl >/dev/null + sh -lc ' + set -eu + apk add --no-cache openssl >/dev/null rm -rf /work mkdir -p /work cp -R /src/. /work/ diff --git a/deploy/scripts/upload-linux-release.sh b/deploy/scripts/upload-linux-release.sh index 31e48f0..a9eb3b3 100755 --- a/deploy/scripts/upload-linux-release.sh +++ b/deploy/scripts/upload-linux-release.sh @@ -7,11 +7,22 @@ if [ "$#" -lt 1 ]; then fi TARGET="$1" -REMOTE_BASE_DIR="${2:-/var/www/abelbirdnest-web}" -ARTIFACT_NAME="${ARTIFACT_NAME:-abelbirdnest-release.tar.gz}" +REMOTE_BASE_DIR="${2:-/var/www/abelbirdnest-web/AbelBirdNest-Stock}" +ARTIFACT_NAME="${ARTIFACT_NAME:-$(ls -t abelbirdnest-release-*.tar.gz 2>/dev/null | head -n 1)}" RELEASE_NAME="${RELEASE_NAME:-$(date +%Y%m%d-%H%M%S)}" REMOTE_RELEASE_DIR="$REMOTE_BASE_DIR/releases/$RELEASE_NAME" +if [ -z "$ARTIFACT_NAME" ]; then + echo "No artifact found. Run ./deploy/scripts/build-linux-release.sh first." >&2 + exit 1 +fi + +if [ ! -f "$ARTIFACT_NAME" ]; then + echo "Artifact not found: $ARTIFACT_NAME" >&2 + echo "Run ./deploy/scripts/build-linux-release.sh first." >&2 + exit 1 +fi + scp "$ARTIFACT_NAME" "$TARGET:$REMOTE_BASE_DIR/" ssh "$TARGET" "mkdir -p '$REMOTE_RELEASE_DIR' && tar -xzf '$REMOTE_BASE_DIR/$ARTIFACT_NAME' -C '$REMOTE_RELEASE_DIR' && ln -sfn '$REMOTE_RELEASE_DIR' '$REMOTE_BASE_DIR/current'" diff --git a/deploy/systemd/abelbirdnest-web.service b/deploy/systemd/abelbirdnest-web.service index d055c0b..d3bab36 100644 --- a/deploy/systemd/abelbirdnest-web.service +++ b/deploy/systemd/abelbirdnest-web.service @@ -4,11 +4,12 @@ After=network.target postgresql.service [Service] Type=simple -WorkingDirectory=/var/www/abelbirdnest-web/AbelBirdNest-Stock +WorkingDirectory=/var/www/abelbirdnest-web/AbelBirdNest-Stock/current Environment=NODE_ENV=production Environment=PORT=3007 +Environment=NODE_OPTIONS=--max-old-space-size=512 EnvironmentFile=/var/www/abelbirdnest-web/AbelBirdNest-Stock/.env.production -ExecStart=/usr/bin/npm run start +ExecStart=/usr/bin/node server.js Restart=always RestartSec=5 User=abelbirdnest diff --git a/docs/codex-handoff-2026-05-21.md b/docs/codex-handoff-2026-05-21.md index 98aaa86..542507f 100644 --- a/docs/codex-handoff-2026-05-21.md +++ b/docs/codex-handoff-2026-05-21.md @@ -112,6 +112,7 @@ Dokumen ini menyimpan konteks kerja terbaru setelah rangkaian patch mobile, logi - `native` - `darwin-arm64` - `debian-openssl-3.0.x` + - `linux-musl-openssl-3.0.x` - File: - [prisma/schema.prisma](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/prisma/schema.prisma) @@ -122,26 +123,71 @@ Dokumen ini menyimpan konteks kerja terbaru setelah rangkaian patch mobile, logi - Script: - [deploy/scripts/build-linux-release.sh](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/deploy/scripts/build-linux-release.sh) - Karakteristik workflow ini: - - build di Docker `linux/amd64` + - build di Docker Alpine Linux agar cocok dengan server Alpine - install `openssl` di container - pakai `NODE_OPTIONS=--max-old-space-size=4096` - copy source ke `/work` agar tidak menimpa `node_modules` lokal Mac - - artifact final: - `abelbirdnest-release.tar.gz` + - artifact final memakai timestamp: + `abelbirdnest-release-YYYYMMDD-HHMMSS.tar.gz` - Artifact final tidak membawa `.env.production` lokal. +### Catatan Tambahan 2026-05-26 + +- Server production memakai Alpine Linux dengan RAM sekitar 1 GB. +- Jangan compile/build di server. +- Artifact harus dibuat dari container Alpine, default script sekarang memakai `node:20-alpine`. +- Runtime server Alpine perlu `nodejs` dan `openssl`. +- Ukuran artifact runtime-only yang normal sekitar `43 MB`. +- Jika artifact membengkak sekitar `120 MB+`, kemungkinan Prisma CLI atau `node_modules` lengkap ikut terbawa. Jangan pakai varian besar itu untuk server 1 GB kecuali memang sengaja. +- Artifact runtime-only tidak dipakai untuk menjalankan `prisma migrate deploy` di server. Untuk update UI/app tanpa perubahan schema DB, cukup extract release baru lalu restart service. +- Jika nanti ada migration database, jalankan migration dari mesin build/admin yang punya dependency lengkap dan akses DB production, bukan dari server kecil. + ## Catatan Deploy Server Saat Ini -- Path app di server: - `/var/www/abelbirdnest-web/AbelBirdNest-Stock` +- Path app aktif di server production saat ini: + `/opt/abelbirdnest-web` - Struktur release yang dipakai sekarang diasumsikan: - - upload artifact ke root repo server + - upload artifact ke root app server: + `/opt/abelbirdnest-web` - extract ke `releases/` - symlink `current` diarahkan ke release aktif -- `systemd` yang benar untuk mode artifact: - - `WorkingDirectory=/var/www/abelbirdnest-web/AbelBirdNest-Stock/current` - - `EnvironmentFile=/var/www/abelbirdnest-web/AbelBirdNest-Stock/.env.production` - - `ExecStart=/usr/bin/node server.js` +- Folder aktif runtime: + `/opt/abelbirdnest-web/current` +- Service server memakai OpenRC, bukan systemd. +- Service OpenRC production yang ditemukan: + - `directory="/opt/abelbirdnest-web/current"` + - `command="/usr/bin/node"` + - `command_args="/opt/abelbirdnest-web/current/server.js"` + - env yang dicek service: + `/opt/abelbirdnest-web/current/.env` +- Karena service mencari `.env` di folder `current`, setiap release baru perlu copy `.env` dari release/current lama ke folder release baru sebelum `current` diarahkan ke release baru. +- Jangan gunakan path lama `/var/www/abelbirdnest-web/AbelBirdNest-Stock` untuk server ini kecuali service production memang sudah diubah. + +### Command Manual Deploy Yang Dipakai + +Di lokal: + +```bash +scp abelbirdnest-release-YYYYMMDD-HHMMSS.tar.gz abelbirdnest@SERVER_IP:/opt/abelbirdnest-web/ +``` + +Di server: + +```bash +cd /opt/abelbirdnest-web +RELEASE_NAME=YYYYMMDD-HHMMSS +mkdir -p /opt/abelbirdnest-web/releases/$RELEASE_NAME +tar -xzf /opt/abelbirdnest-web/abelbirdnest-release-$RELEASE_NAME.tar.gz -C /opt/abelbirdnest-web/releases/$RELEASE_NAME +cp /opt/abelbirdnest-web/current/.env /opt/abelbirdnest-web/releases/$RELEASE_NAME/.env +mv /opt/abelbirdnest-web/current /opt/abelbirdnest-web/current-backup-$(date +%Y%m%d-%H%M%S) 2>/dev/null || true +ln -sfn /opt/abelbirdnest-web/releases/$RELEASE_NAME /opt/abelbirdnest-web/current +sudo chown -R abelbirdnest:abelbirdnest /opt/abelbirdnest-web +sudo rc-service abelbirdnest-web restart +sudo rc-service abelbirdnest-web status +curl -I http://127.0.0.1:3007/login +``` + +Jika `ln -sfn ... current` gagal dengan pesan `File exists`, berarti `current` masih folder biasa. Backup/rename folder itu dulu, lalu ulangi `ln -sfn`. ## Verifikasi yang Sudah Dilakukan diff --git a/docs/deploy-alpine-update.md b/docs/deploy-alpine-update.md new file mode 100644 index 0000000..b81c180 --- /dev/null +++ b/docs/deploy-alpine-update.md @@ -0,0 +1,121 @@ +# Update Deploy Alpine + +Panduan ini untuk server production Alpine Linux x86_64 RAM 1 GB. Server tidak menjalankan build/compile. + +## 1. Build Artifact Di Mesin Lokal/Build Machine + +Mesin build harus punya Docker. + +```bash +./deploy/scripts/build-linux-release.sh +``` + +Script memakai `node:20-alpine`, menjalankan `npm ci`, `prisma generate`, `next build`, lalu membuat artifact bertanggal: + +```bash +abelbirdnest-release-YYYYMMDD-HHMMSS.tar.gz +``` + +## 2. Upload Artifact Ke Server + +```bash +./deploy/scripts/upload-linux-release.sh abelbirdnest@SERVER_IP /opt/abelbirdnest-web +``` + +Script otomatis memilih artifact terbaru dengan pola `abelbirdnest-release-*.tar.gz`, upload artifact, extract ke: + +```bash +/opt/abelbirdnest-web/releases/ +``` + +Lalu update symlink: + +```bash +/opt/abelbirdnest-web/current +``` + +## 3. Prasyarat Server Alpine + +Jalankan sekali di server: + +```bash +sudo apk add --no-cache nodejs openssl +``` + +Pastikan env production ada di: + +```bash +/var/www/abelbirdnest-web/AbelBirdNest-Stock/.env.production +``` + +## 4. Setup Service OpenRC + +Copy template service dari mesin lokal/build machine: + +```bash +scp deploy/openrc/abelbirdnest-web abelbirdnest@SERVER_IP:/tmp/abelbirdnest-web +ssh abelbirdnest@SERVER_IP +sudo mv /tmp/abelbirdnest-web /etc/init.d/abelbirdnest-web +sudo chmod +x /etc/init.d/abelbirdnest-web +sudo rc-update add abelbirdnest-web default +``` + +Service menjalankan: + +```bash +cd /var/www/abelbirdnest-web/AbelBirdNest-Stock/current +PORT=3007 NODE_ENV=production NODE_OPTIONS=--max-old-space-size=512 node server.js +``` + +## 5. Setelah Upload + +Masuk server: + +```bash +ssh abelbirdnest@SERVER_IP +cd /var/www/abelbirdnest-web/AbelBirdNest-Stock/current +``` + +Artifact release dibuat ramping untuk runtime Next.js standalone. Jangan menjalankan `npm install`, `npm run build`, atau `prisma migrate deploy` di server kecil. + +Jika update berikutnya membawa migration database, jalankan migration dari mesin build/admin yang punya dependency lengkap dan akses ke database production. Untuk update UI/app tanpa migration, langsung restart service. + +Load env hanya jika perlu tes manual: + +```bash +set -a +. /var/www/abelbirdnest-web/AbelBirdNest-Stock/.env.production +set +a +``` + +Restart service: + +```bash +sudo rc-service abelbirdnest-web restart +sudo rc-service abelbirdnest-web status +``` + +## 6. Verifikasi + +```bash +curl -I http://127.0.0.1:3007/login +tail -n 100 /var/log/abelbirdnest-web.err +tail -n 100 /var/log/abelbirdnest-web.log +``` + +Jika memakai reverse proxy, cek domain public setelah service sehat. + +## 7. Rollback Cepat + +Lihat release yang tersedia: + +```bash +ls -lt /var/www/abelbirdnest-web/AbelBirdNest-Stock/releases +``` + +Arahkan `current` ke release sebelumnya: + +```bash +sudo ln -sfn /var/www/abelbirdnest-web/AbelBirdNest-Stock/releases/NAMA_RELEASE_LAMA /var/www/abelbirdnest-web/AbelBirdNest-Stock/current +sudo rc-service abelbirdnest-web restart +``` diff --git a/docs/deploy-production.md b/docs/deploy-production.md index a7acac4..64056ff 100644 --- a/docs/deploy-production.md +++ b/docs/deploy-production.md @@ -354,7 +354,7 @@ sudo systemctl restart abelbirdnest-web ## 13. Alternatif Deploy: Build di Lokal, Jalankan di Server -Repo ini sekarang mendukung output Next.js `standalone`, sehingga build bisa dilakukan di lingkungan Linux lokal/container lalu artifact di-upload ke server. +Repo ini sekarang mendukung output Next.js `standalone`, sehingga build dilakukan di lingkungan Linux lokal/container lalu artifact di-upload ke server. Server production tidak menjalankan `npm run build`. ### Kapan jalur ini dipakai @@ -366,8 +366,10 @@ Pakai cara ini jika: ### Prasyarat -- build harus dilakukan di Linux, atau container Linux +- build harus dilakukan di Linux Alpine, atau container Linux Alpine - versi Node saat build harus sama dengan server +- server Alpine perlu `nodejs` dan `openssl` terpasang +- untuk server RAM 1 GB, runtime disarankan memakai `NODE_OPTIONS=--max-old-space-size=512` - database migration tetap dijalankan di server ### Script yang disediakan @@ -386,16 +388,16 @@ deploy/scripts/upload-linux-release.sh ### Contoh build artifact di lokal -Pastikan `.env.production` sudah ada di root repo jika ingin ikut dibawa ke artifact. +Pastikan Docker tersedia di mesin lokal/build machine. Script default memakai image `node:20-alpine` agar artifact cocok dengan server Alpine Linux. `.env.production` production tetap disimpan di server, bukan dibawa di artifact. ```bash ./deploy/scripts/build-linux-release.sh ``` -Artifact default yang dihasilkan: +Artifact default yang dihasilkan memakai timestamp: ```bash -abelbirdnest-release.tar.gz +abelbirdnest-release-YYYYMMDD-HHMMSS.tar.gz ``` Isi artifact: @@ -404,19 +406,19 @@ Isi artifact: - static assets `.next/static` - folder `public` - folder `prisma` -- `.env.production` jika tersedia +- tidak membawa `.env.production` ### Contoh upload ke server ```bash -./deploy/scripts/upload-linux-release.sh abelbirdnest@server /var/www/abelbirdnest-web +./deploy/scripts/upload-linux-release.sh abelbirdnest@server /var/www/abelbirdnest-web/AbelBirdNest-Stock ``` Setelah upload, script akan: - copy artifact ke server -- extract ke `/var/www/abelbirdnest-web/releases/` -- update symlink `/var/www/abelbirdnest-web/current` +- extract ke `/var/www/abelbirdnest-web/AbelBirdNest-Stock/releases/` +- update symlink `/var/www/abelbirdnest-web/AbelBirdNest-Stock/current` ### Langkah setelah upload di server @@ -424,13 +426,11 @@ Masuk ke server: ```bash ssh abelbirdnest@server -cd /var/www/abelbirdnest-web/current -set -a -source .env.production -set +a -npx prisma migrate deploy +cd /var/www/abelbirdnest-web/AbelBirdNest-Stock/current ``` +Artifact standalone dibuat untuk runtime. Jangan menjalankan `npm install`, `npm run build`, atau `prisma migrate deploy` di server kecil. Jika rilis membawa migration database, jalankan migration dari mesin build/admin yang punya dependency lengkap dan akses ke database production. + Untuk menjalankan standalone app secara manual: ```bash @@ -440,7 +440,7 @@ PORT=3007 node server.js Jika memakai `systemd`, `WorkingDirectory` service harus diarahkan ke: ```bash -/var/www/abelbirdnest-web/current +/var/www/abelbirdnest-web/AbelBirdNest-Stock/current ``` dan `ExecStart` menjadi: diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a240c47..0127fe1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,6 +1,6 @@ generator client { provider = "prisma-client-js" - binaryTargets = ["native", "darwin-arm64", "debian-openssl-3.0.x"] + binaryTargets = ["native", "darwin-arm64", "debian-openssl-3.0.x", "linux-musl-openssl-3.0.x"] } datasource db { diff --git a/src/app/api/v1/auth/login/route.ts b/src/app/api/v1/auth/login/route.ts index 0f4cb49..65a700e 100644 --- a/src/app/api/v1/auth/login/route.ts +++ b/src/app/api/v1/auth/login/route.ts @@ -19,9 +19,12 @@ import { prisma } from "@/lib/prisma"; const loginSchema = z.object({ identity: z.string().trim().min(1, "Email atau username wajib diisi"), - password: z.string().min(1, "Password wajib diisi") + password: z.string().min(1, "Password wajib diisi"), + remember_me: z.boolean().optional().default(false) }); +const REMEMBER_ME_TTL_SECONDS = 60 * 60 * 24 * 30; + export async function POST(request: Request) { try { await ensureAuthBootstrap(); @@ -39,7 +42,7 @@ export async function POST(request: Request) { ); } - const { identity, password } = parsed.data; + const { identity, password, remember_me: rememberMe } = parsed.data; const normalizedIdentity = identity.toLowerCase(); const [mustVerifyEmail, sessionTtlSeconds] = await Promise.all([ isEmailVerificationRequired(), @@ -100,7 +103,10 @@ export async function POST(request: Request) { email: user.email, username: user.username }; - const sessionToken = createSessionToken(sessionUser, sessionTtlSeconds); + const effectiveSessionTtlSeconds = rememberMe + ? REMEMBER_ME_TTL_SECONDS + : sessionTtlSeconds || getSessionTtlSeconds(); + const sessionToken = createSessionToken(sessionUser, effectiveSessionTtlSeconds); const response = NextResponse.json({ message: "Login berhasil", @@ -109,14 +115,14 @@ export async function POST(request: Request) { redirect_to: getDefaultPathForRole(sessionUser.role), session_token: sessionToken, token_type: "Bearer", - expires_in: sessionTtlSeconds || getSessionTtlSeconds() + expires_in: effectiveSessionTtlSeconds } }); response.cookies.set( AUTH_COOKIE_NAME, sessionToken, - getSessionCookieOptions(sessionTtlSeconds) + getSessionCookieOptions(effectiveSessionTtlSeconds) ); await createAuditTrailSafe({ diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 6b6f082..4d0909f 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -22,12 +22,12 @@ export default async function DashboardPage() { title="Dashboard Operasional" description="Status real-time lot persediaan, transaksi masuk, dan titik perhatian operasional gudang walet." > -
+
{dashboard.metrics.map((metric) => ( ))}
-
+
@@ -80,7 +80,7 @@ export default async function DashboardPage() { recentActivity={dashboard.recentActivity} />
-
+
diff --git a/src/features/auth/components/login-client.tsx b/src/features/auth/components/login-client.tsx index 696bbed..0a22d5c 100644 --- a/src/features/auth/components/login-client.tsx +++ b/src/features/auth/components/login-client.tsx @@ -3,18 +3,21 @@ import Link from "next/link"; import { Eye, EyeOff, Lock, LogIn, User } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; -import { FormEvent, useState } from "react"; +import { FormEvent, useEffect, useState } from "react"; import { AppLogo } from "@/components/branding/app-logo"; import { LanguageSwitcher } from "@/components/layout/language-switcher"; import { useLocale } from "@/components/providers/locale-provider"; +const REMEMBERED_IDENTITY_KEY = "abelbirdnest_remembered_identity"; + export function LoginClient() { const { dict } = useLocale(); const router = useRouter(); const searchParams = useSearchParams(); const [identity, setIdentity] = useState(""); const [password, setPassword] = useState(""); + const [rememberDevice, setRememberDevice] = useState(true); const [showPassword, setShowPassword] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -22,6 +25,15 @@ export function LoginClient() { const [helperSubmitting, setHelperSubmitting] = useState<"reset" | "verify" | null>(null); const [helperMessage, setHelperMessage] = useState(null); + useEffect(() => { + const rememberedIdentity = window.localStorage.getItem(REMEMBERED_IDENTITY_KEY); + + if (rememberedIdentity) { + setIdentity(rememberedIdentity); + setRememberDevice(true); + } + }, []); + async function submitHelper(type: "reset" | "verify") { setHelperSubmitting(type); setHelperMessage(null); @@ -68,7 +80,8 @@ export function LoginClient() { }, body: JSON.stringify({ identity, - password + password, + remember_me: rememberDevice }) }); @@ -87,6 +100,12 @@ export function LoginClient() { throw new Error(firstError ?? payload.message ?? dict.common.requestFailed); } + if (rememberDevice) { + window.localStorage.setItem(REMEMBERED_IDENTITY_KEY, identity.trim()); + } else { + window.localStorage.removeItem(REMEMBERED_IDENTITY_KEY); + } + const next = searchParams.get("next") || payload.data?.redirect_to || "/dashboard"; router.push(next); router.refresh(); @@ -148,7 +167,8 @@ export function LoginClient() { value={identity} onChange={(event) => setIdentity(event.target.value)} className="ops-input pl-11" - placeholder="name@company.com" + placeholder="name@company.com" + autoComplete="username" />
@@ -166,6 +186,7 @@ export function LoginClient() { onChange={(event) => setPassword(event.target.value)} className="ops-input pl-11 pr-12" placeholder="••••••••" + autoComplete="current-password" />