feat: auth for admin and pengelola

This commit is contained in:
pahmiudahgede 2025-07-09 02:11:45 +07:00
parent 83729c9950
commit 83942347f5
21 changed files with 1556 additions and 307 deletions

View File

@ -1,4 +1,3 @@
// app/components/layoutadmin/header.tsx
import { useState } from "react"; import { useState } from "react";
import { Form } from "@remix-run/react"; import { Form } from "@remix-run/react";
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
@ -28,20 +27,33 @@ import {
PanelLeftClose, PanelLeftClose,
PanelLeft PanelLeft
} from "lucide-react"; } from "lucide-react";
import { SessionData } from "~/sessions.server"; // Import SessionData type
interface PengelolaHeaderProps { interface PengelolaHeaderProps {
onMenuClick: () => void; onMenuClick: () => void;
sidebarCollapsed: boolean; sidebarCollapsed: boolean;
isMobile: boolean; isMobile: boolean;
user: SessionData; // Add user prop
} }
export function PengelolaHeader({ export function PengelolaHeader({
onMenuClick, onMenuClick,
sidebarCollapsed, sidebarCollapsed,
isMobile isMobile,
user // Add user prop
}: PengelolaHeaderProps) { }: PengelolaHeaderProps) {
// const [isDark, setIsDark] = useState(false); // const [isDark, setIsDark] = useState(false);
// Get user initials for avatar fallback
const getUserInitials = (name: string) => {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
};
return ( return (
<header className="sticky top-0 flex w-full bg-white border-b border-gray-200 z-40 dark:border-gray-700 dark:bg-gray-800"> <header className="sticky top-0 flex w-full bg-white border-b border-gray-200 z-40 dark:border-gray-700 dark:bg-gray-800">
<div className="flex flex-col items-center justify-between grow lg:flex-row lg:px-6"> <div className="flex flex-col items-center justify-between grow lg:flex-row lg:px-6">
@ -186,19 +198,19 @@ export function PengelolaHeader({
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Avatar className="h-8 w-8"> <Avatar className="h-8 w-8">
<AvatarImage <AvatarImage
src="https://github.com/leerob.png" src={""}
alt="User" alt={"User"}
/> />
<AvatarFallback className="bg-blue-600 text-white"> <AvatarFallback className="bg-blue-600 text-white">
MU {getUserInitials("User")}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="hidden sm:block text-left"> <div className="hidden sm:block text-left">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100"> <div className="text-sm font-medium text-gray-900 dark:text-gray-100">
Fahmi Kurniawan {user.sessionId || "User"}
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-300"> <div className="text-xs text-gray-500 dark:text-gray-300">
Pengelola {user.role || "Pengelola"}
</div> </div>
</div> </div>
<ChevronDown className="h-4 w-4 text-gray-500" /> <ChevronDown className="h-4 w-4 text-gray-500" />
@ -210,9 +222,9 @@ export function PengelolaHeader({
className="w-56 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700" className="w-56 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700"
> >
<div className="px-2 py-1.5 text-sm text-gray-700 dark:text-gray-200"> <div className="px-2 py-1.5 text-sm text-gray-700 dark:text-gray-200">
<div className="font-medium">Fahmi Kurniawan</div> <div className="font-medium">{user.phone || "User"}</div>
<div className="text-xs text-gray-500 dark:text-gray-300"> <div className="text-xs text-gray-500 dark:text-gray-300">
pengelola@example.com {user.email || "user@example.com"}
</div> </div>
</div> </div>
<DropdownMenuSeparator /> <DropdownMenuSeparator />

View File

@ -1,12 +1,17 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { PengelolaSidebar } from "./sidebar"; import { PengelolaSidebar } from "./sidebar";
import { PengelolaHeader } from "./header"; import { PengelolaHeader } from "./header";
import { SessionData } from "~/sessions.server"; // Import SessionData type
interface PengelolaLayoutWrapperProps { interface PengelolaLayoutWrapperProps {
children: React.ReactNode; children: React.ReactNode;
user: SessionData; // Add user prop
} }
export function PengelolaLayoutWrapper({ children }: PengelolaLayoutWrapperProps) { export function PengelolaLayoutWrapper({
children,
user
}: PengelolaLayoutWrapperProps) {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
@ -47,6 +52,7 @@ export function PengelolaLayoutWrapper({ children }: PengelolaLayoutWrapperProps
onClose={() => setSidebarOpen(false)} onClose={() => setSidebarOpen(false)}
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
// user={user}
/> />
{/* Mobile overlay */} {/* Mobile overlay */}
@ -68,6 +74,7 @@ export function PengelolaLayoutWrapper({ children }: PengelolaLayoutWrapperProps
onMenuClick={handleToggleSidebar} onMenuClick={handleToggleSidebar}
sidebarCollapsed={sidebarCollapsed} sidebarCollapsed={sidebarCollapsed}
isMobile={isMobile} isMobile={isMobile}
user={user}
/> />
{/* Page content */} {/* Page content */}

151
app/lib/api-client.ts Normal file
View File

@ -0,0 +1,151 @@
import axios, {
AxiosError,
AxiosInstance,
InternalAxiosRequestConfig
} from "axios";
export interface ApiResponse<T = any> {
meta: {
status: number;
message: string;
};
data?: T;
}
const apiClient: AxiosInstance = axios.create({
baseURL: process.env.RIJIG_API_BASE_URL,
timeout: 30000,
headers: {
"Content-Type": "application/json"
}
});
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
if (process.env.RIJIG_API_KEY) {
config.headers["X-API-Key"] = process.env.RIJIG_API_KEY;
}
if (process.env.RIJIG_API_BASE_URL?.includes("ngrok")) {
config.headers["ngrok-skip-browser-warning"] = "true";
}
return config;
},
(error: AxiosError) => {
return Promise.reject(error);
}
);
let isRefreshing = false;
let refreshSubscribers: ((token: string) => void)[] = [];
function subscribeTokenRefresh(cb: (token: string) => void) {
refreshSubscribers.push(cb);
}
function onTokenRefreshed(token: string) {
refreshSubscribers.forEach((cb) => cb(token));
refreshSubscribers = [];
}
let getRefreshToken: (() => string | null) | null = null;
let onTokenRefreshSuccess: ((data: any) => void) | null = null;
let onTokenRefreshError: (() => void) | null = null;
export function setTokenRefreshHandlers(handlers: {
getRefreshToken: () => string | null;
onSuccess: (data: any) => void;
onError: () => void;
}) {
getRefreshToken = handlers.getRefreshToken;
onTokenRefreshSuccess = handlers.onSuccess;
onTokenRefreshError = handlers.onError;
}
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError<ApiResponse>) => {
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
if (error.response) {
const { status, data } = error.response;
switch (status) {
case 401:
if (!originalRequest._retry && getRefreshToken) {
originalRequest._retry = true;
if (!isRefreshing) {
isRefreshing = true;
try {
const refreshToken = getRefreshToken();
if (!refreshToken) throw new Error("No refresh token");
const response = await axios.post(
`${process.env.RIJIG_API_BASE_URL}/auth/refresh-token`,
{ refresh_token: refreshToken },
{
headers: {
"X-API-Key": process.env.RIJIG_API_KEY,
"ngrok-skip-browser-warning": "true"
}
}
);
const { access_token } = response.data.data;
apiClient.defaults.headers.common[
"Authorization"
] = `Bearer ${access_token}`;
originalRequest.headers[
"Authorization"
] = `Bearer ${access_token}`;
onTokenRefreshed(access_token);
if (onTokenRefreshSuccess) {
onTokenRefreshSuccess(response.data.data);
}
isRefreshing = false;
return apiClient(originalRequest);
} catch (refreshError) {
isRefreshing = false;
if (onTokenRefreshError) {
onTokenRefreshError();
}
return Promise.reject(refreshError);
}
}
return new Promise((resolve) => {
subscribeTokenRefresh((token: string) => {
originalRequest.headers["Authorization"] = `Bearer ${token}`;
resolve(apiClient(originalRequest));
});
});
}
break;
case 403:
break;
case 404:
break;
case 422:
break;
case 500:
break;
}
}
return Promise.reject(error);
}
);
export default apiClient;

View File

@ -0,0 +1,39 @@
import { setTokenRefreshHandlers } from "~/lib/api-client";
import type { SessionData } from "~/sessions.server";
export function setupTokenRefresh(sessionData?: SessionData | null) {
if (typeof window === "undefined") return;
if (sessionData) {
if (sessionData.accessToken) {
window.sessionStorage.setItem("access_token", sessionData.accessToken);
}
if (sessionData.refreshToken) {
window.sessionStorage.setItem("refresh_token", sessionData.refreshToken);
}
}
setTokenRefreshHandlers({
getRefreshToken: () => {
return window.sessionStorage.getItem("refresh_token");
},
onSuccess: (data) => {
if (data.access_token) {
window.sessionStorage.setItem("access_token", data.access_token);
}
if (data.refresh_token) {
window.sessionStorage.setItem("refresh_token", data.refresh_token);
}
},
onError: () => {
window.sessionStorage.removeItem("access_token");
window.sessionStorage.removeItem("refresh_token");
if (window.location.pathname.startsWith("/sys-rijig-adminpanel")) {
window.location.href = "/sys-rijig-administrator/sign-infirst";
} else if (window.location.pathname.startsWith("/pengelola")) {
window.location.href = "/authpengelola";
}
}
});
}

View File

@ -7,14 +7,18 @@ import {
ScrollRestoration, ScrollRestoration,
useLoaderData useLoaderData
} from "@remix-run/react"; } from "@remix-run/react";
import {
json,
type LinksFunction,
type LoaderFunctionArgs
} from "@remix-run/node";
import clsx from "clsx"; import clsx from "clsx";
import { import {
PreventFlashOnWrongTheme, PreventFlashOnWrongTheme,
ThemeProvider, ThemeProvider,
useTheme useTheme
} from "remix-themes"; } from "remix-themes";
import { themeSessionResolver } from "./sessions.server"; import { getUserSession, themeSessionResolver } from "./sessions.server";
import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node";
import { ProgressProvider } from "@bprogress/remix"; import { ProgressProvider } from "@bprogress/remix";
import "./tailwind.css"; import "./tailwind.css";
@ -38,9 +42,27 @@ export const links: LinksFunction = () => [
export async function loader({ request }: LoaderFunctionArgs) { export async function loader({ request }: LoaderFunctionArgs) {
const { getTheme } = await themeSessionResolver(request); const { getTheme } = await themeSessionResolver(request);
return { const userSession = await getUserSession(request);
const sessionData = userSession
? {
accessToken: userSession.accessToken,
refreshToken: userSession.refreshToken,
sessionId: userSession.sessionId,
role: userSession.role,
deviceId: userSession.deviceId,
email: userSession.email,
phone: userSession.phone,
tokenType: userSession.tokenType,
registrationStatus: userSession.registrationStatus,
nextStep: userSession.nextStep
}
: null;
return json({
sessionData,
theme: getTheme() theme: getTheme()
}; });
} }
export default function AppWithProviders() { export default function AppWithProviders() {

31
app/routes/$.tsx Normal file
View File

@ -0,0 +1,31 @@
import { json, type LoaderFunctionArgs } from "@remix-run/node";
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
// Handle Chrome DevTools and other well-known requests
if (url.pathname.startsWith("/.well-known/") ||
url.pathname.includes("com.chrome.devtools")) {
return json({}, { status: 404 });
}
// For other unknown routes, throw 404
throw new Response("Not Found", { status: 404 });
}
export default function CatchAll() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold text-gray-900 dark:text-gray-100">404</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">Halaman tidak ditemukan</p>
<a
href="/"
className="mt-4 inline-block px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Kembali ke Home
</a>
</div>
</div>
);
}

View File

@ -12,11 +12,14 @@ import {
ArrowRight, ArrowRight,
AlertCircle, AlertCircle,
Loader2, Loader2,
MessageSquare,
CheckCircle, CheckCircle,
LogIn, LogIn,
Shield Shield
} from "lucide-react"; } from "lucide-react";
import { getSession, commitSession } from "~/sessions.server";
import { generateDeviceId, validatePhoneNumber } from "~/utils/auth-utils";
import pengelolaAuthService from "~/services/auth/pengelola.service";
import type { ApiResponse } from "~/lib/api-client";
// Progress Indicator Component untuk Login (3 steps) // Progress Indicator Component untuk Login (3 steps)
const LoginProgressIndicator = ({ currentStep = 1, totalSteps = 3 }) => { const LoginProgressIndicator = ({ currentStep = 1, totalSteps = 3 }) => {
@ -79,21 +82,41 @@ export const action = async ({
if (!phone) { if (!phone) {
errors.phone = "Nomor WhatsApp wajib diisi"; errors.phone = "Nomor WhatsApp wajib diisi";
} else { } else if (!validatePhoneNumber(phone)) {
// Validasi format nomor HP Indonesia
const phoneRegex = /^62[0-9]{9,14}$/;
if (!phoneRegex.test(phone)) {
errors.phone = "Format: 628xxxxxxxxx (9-14 digit setelah 62)"; errors.phone = "Format: 628xxxxxxxxx (9-14 digit setelah 62)";
} }
}
if (Object.keys(errors).length > 0) { if (Object.keys(errors).length > 0) {
return json<RequestOTPLoginActionData>({ errors }, { status: 400 }); return json<RequestOTPLoginActionData>({ errors }, { status: 400 });
} }
// Simulasi cek apakah nomor terdaftar // Generate device ID untuk session ini
const registeredPhones = ["6281234567890", "6281234567891", "6281234567892"]; const deviceId = generateDeviceId("PengelolaLogin");
if (!registeredPhones.includes(phone)) {
try {
// Request OTP untuk login
const response = await pengelolaAuthService.requestOtpLogin({
phone,
role_name: "pengelola"
});
// Simpan data ke session untuk langkah berikutnya
const session = await getSession(request);
session.set("tempLoginPhone", phone);
session.set("tempLoginDeviceId", deviceId);
session.set("tempLoginOtpSentAt", new Date().toISOString());
// Redirect ke step berikutnya
return redirect("/authpengelola/verifyotptologin", {
headers: {
"Set-Cookie": await commitSession(session)
}
});
} catch (error: any) {
console.error("Request OTP login error:", error);
// Handle specific API errors
if (error.response?.status === 404) {
return json<RequestOTPLoginActionData>( return json<RequestOTPLoginActionData>(
{ {
errors: { errors: {
@ -104,21 +127,25 @@ export const action = async ({
); );
} }
// Simulasi kirim OTP - dalam implementasi nyata, integrate dengan WhatsApp Business API if (error.response?.status === 429) {
try {
console.log("Sending login OTP to WhatsApp:", phone);
// Simulasi delay API call
await new Promise((resolve) => setTimeout(resolve, 1000));
// Redirect ke step berikutnya dengan nomor HP
return redirect(
`/authpengelola/verifyotptologin?phone=${encodeURIComponent(phone)}`
);
} catch (error) {
return json<RequestOTPLoginActionData>( return json<RequestOTPLoginActionData>(
{ {
errors: { general: "Gagal mengirim OTP. Silakan coba lagi." } errors: {
general: "Terlalu banyak permintaan. Silakan tunggu beberapa menit."
}
},
{ status: 429 }
);
}
// General error
const errorMessage =
error.response?.data?.meta?.message ||
"Gagal mengirim OTP. Silakan coba lagi.";
return json<RequestOTPLoginActionData>(
{
errors: { general: errorMessage }
}, },
{ status: 500 } { status: 500 }
); );
@ -255,18 +282,6 @@ export default function RequestOTPForLogin() {
</div> </div>
</div> </div>
{/* Demo Info */}
<div className="p-3 bg-yellow-50 dark:bg-yellow-950/30 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<p className="text-xs font-medium text-yellow-800 dark:text-yellow-300 mb-2">
Demo - Nomor Terdaftar:
</p>
<div className="space-y-1 text-xs text-yellow-700 dark:text-yellow-400">
<p> 6281234567890</p>
<p> 6281234567891</p>
<p> 6281234567892</p>
</div>
</div>
{/* Submit Button */} {/* Submit Button */}
<Button <Button
type="submit" type="submit"

View File

@ -1,6 +1,58 @@
import { Outlet } from "@remix-run/react"; import { LoaderFunctionArgs } from "@remix-run/node";
import { json, Outlet, redirect } from "@remix-run/react";
import { Recycle, Leaf } from "lucide-react"; import { Recycle, Leaf } from "lucide-react";
import { getUserSession, SessionData } from "~/sessions.server";
interface LoaderData {
isAuthenticated: boolean;
user?: SessionData;
}
export async function loader({ request }: LoaderFunctionArgs) {
const userSession = await getUserSession(request);
// Jika user sudah ada session dan role adalah pengelola
if (userSession && userSession.role === "pengelola") {
// Jika registration status sudah complete, redirect ke dashboard
if (userSession.registrationStatus === "complete") {
return redirect("/pengelola/dashboard");
}
// Jika belum complete, redirect ke step yang sesuai berdasarkan status
const url = new URL(request.url);
const currentPath = url.pathname;
// Jangan redirect jika sudah ada di step yang benar
const correctPaths = [
"/authpengelola/completingcompanyprofile",
"/authpengelola/waitingapprovalfromadministrator",
"/authpengelola/createanewpin"
];
if (!correctPaths.includes(currentPath)) {
switch (userSession.registrationStatus) {
case "uncomplete":
return redirect("/authpengelola/completingcompanyprofile");
case "awaiting_approval":
return redirect("/authpengelola/waitingapprovalfromadministrator");
case "approved":
return redirect("/authpengelola/createanewpin");
default:
break;
}
}
}
// Jika user sudah ada session tapi bukan pengelola (misalnya admin)
if (userSession && userSession.role === "administrator") {
return redirect("/sys-rijig-adminpanel/dashboard");
}
return json<LoaderData>({
isAuthenticated: !!userSession,
user: userSession || undefined
});
}
export default function AuthPengelolaLayout() { export default function AuthPengelolaLayout() {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-green-50 via-blue-50 to-purple-50"> <div className="min-h-screen bg-gradient-to-br from-green-50 via-blue-50 to-purple-50">

View File

@ -30,6 +30,15 @@ import {
KeyRound, KeyRound,
Fingerprint Fingerprint
} from "lucide-react"; } from "lucide-react";
import {
getSession,
commitSession,
createUserSession
} from "~/sessions.server";
import { validatePin } from "~/utils/auth-utils";
import pengelolaAuthService from "~/services/auth/pengelola.service";
import commonAuthService from "~/services/auth/common.service";
import type { AuthTokenData } from "~/types/auth.types";
// Progress Indicator Component untuk Login (3 steps) // Progress Indicator Component untuk Login (3 steps)
const LoginProgressIndicator = ({ currentStep = 3, totalSteps = 3 }) => { const LoginProgressIndicator = ({ currentStep = 3, totalSteps = 3 }) => {
@ -75,7 +84,8 @@ const LoginProgressIndicator = ({ currentStep = 3, totalSteps = 3 }) => {
// Interfaces // Interfaces
interface LoaderData { interface LoaderData {
phone: string; phone: string;
lastLoginAt?: string; deviceId: string;
tokenData: AuthTokenData;
} }
interface VerifyPINActionData { interface VerifyPINActionData {
@ -89,17 +99,22 @@ interface VerifyPINActionData {
export const loader = async ({ export const loader = async ({
request request
}: LoaderFunctionArgs): Promise<Response> => { }: LoaderFunctionArgs): Promise<Response> => {
const url = new URL(request.url); const session = await getSession(request);
const phone = url.searchParams.get("phone"); const phone = session.get("tempLoginPhone");
const deviceId = session.get("tempLoginDeviceId");
const tokenData = session.get("tempLoginTokenData");
if (!phone) { if (!phone || !deviceId || !tokenData) {
return redirect("/authpengelola/requestotpforlogin"); return redirect("/authpengelola/requestotpforlogin");
} }
// Simulasi data user - dalam implementasi nyata, ambil dari database // Set auth token for API calls
commonAuthService.setAuthToken(tokenData.access_token);
return json<LoaderData>({ return json<LoaderData>({
phone, phone,
lastLoginAt: "2025-07-05T10:30:00Z" // contoh last login deviceId,
tokenData
}); });
}; };
@ -107,26 +122,95 @@ export const action = async ({
request request
}: ActionFunctionArgs): Promise<Response> => { }: ActionFunctionArgs): Promise<Response> => {
const formData = await request.formData(); const formData = await request.formData();
const phone = formData.get("phone") as string;
const pin = formData.get("pin") as string; const pin = formData.get("pin") as string;
const session = await getSession(request);
const phone = session.get("tempLoginPhone");
const deviceId = session.get("tempLoginDeviceId");
const tokenData = session.get("tempLoginTokenData");
if (!phone || !deviceId || !tokenData) {
return redirect("/authpengelola/requestotpforlogin");
}
// Validation // Validation
const errors: { pin?: string; general?: string } = {}; const errors: { pin?: string; general?: string } = {};
if (!pin || pin.length !== 6) { if (!pin) {
errors.pin = "PIN harus 6 digit"; errors.pin = "PIN wajib diisi";
} else if (!/^\d{6}$/.test(pin)) { } else if (!validatePin(pin)) {
errors.pin = "PIN hanya boleh berisi angka"; errors.pin = "PIN harus 6 digit angka";
} }
if (Object.keys(errors).length > 0) { if (Object.keys(errors).length > 0) {
return json<VerifyPINActionData>({ errors }, { status: 400 }); return json<VerifyPINActionData>({ errors }, { status: 400 });
} }
// Simulasi verifikasi PIN - dalam implementasi nyata, hash dan compare dengan database // Set auth token for API calls
const validPIN = "123456"; // Demo PIN commonAuthService.setAuthToken(tokenData.access_token);
if (pin !== validPIN) { try {
// Verify PIN
const response = await pengelolaAuthService.verifyPin({
userpin: pin
});
const finalTokenData = response.data;
// Check if finalTokenData exists
if (!finalTokenData) {
return json<VerifyPINActionData>(
{
errors: { general: "Response data tidak valid dari server" }
},
{ status: 500 }
);
}
// Validate required fields
if (
!finalTokenData.access_token ||
!finalTokenData.refresh_token ||
!finalTokenData.session_id
) {
return json<VerifyPINActionData>(
{
errors: { general: "Data token tidak lengkap dari server" }
},
{ status: 500 }
);
}
// Create user session dengan semua data yang diperlukan
const sessionData = {
accessToken: finalTokenData.access_token,
refreshToken: finalTokenData.refresh_token,
sessionId: finalTokenData.session_id,
role: "pengelola" as const,
deviceId,
phone,
tokenType: finalTokenData.token_type,
registrationStatus: finalTokenData.registration_status,
nextStep: finalTokenData.next_step
};
// Clear temporary session data
session.unset("tempLoginPhone");
session.unset("tempLoginDeviceId");
session.unset("tempLoginTokenData");
session.unset("tempLoginOtpSentAt");
// Create user session and redirect to dashboard
return createUserSession({
request,
sessionData,
redirectTo: "/pengelola/dashboard"
});
} catch (error: any) {
console.error("Verify PIN error:", error);
// Handle specific API errors
if (error.response?.status === 401) {
return json<VerifyPINActionData>( return json<VerifyPINActionData>(
{ {
errors: { pin: "PIN yang Anda masukkan salah. Silakan coba lagi." } errors: { pin: "PIN yang Anda masukkan salah. Silakan coba lagi." }
@ -135,25 +219,36 @@ export const action = async ({
); );
} }
// PIN valid, buat session dan redirect ke dashboard if (error.response?.status === 429) {
try {
console.log("PIN verified for phone:", phone);
// Simulasi delay dan create session
await new Promise((resolve) => setTimeout(resolve, 1500));
// Dalam implementasi nyata:
// const session = await getSession(request.headers.get("Cookie"));
// session.set("pengelolaId", userId);
// session.set("pengelolaPhone", phone);
// session.set("loginTime", new Date().toISOString());
// Redirect ke dashboard pengelola
return redirect("/pengelola/dashboard");
} catch (error) {
return json<VerifyPINActionData>( return json<VerifyPINActionData>(
{ {
errors: { general: "Gagal login. Silakan coba lagi." } errors: {
pin: "Terlalu banyak percobaan. Silakan tunggu beberapa menit."
}
},
{ status: 429 }
);
}
if (error.response?.status === 403) {
return json<VerifyPINActionData>(
{
errors: {
general: "Akun Anda dikunci sementara. Hubungi administrator."
}
},
{ status: 403 }
);
}
// General error
const errorMessage =
error.response?.data?.meta?.message ||
"Gagal memverifikasi PIN. Silakan coba lagi.";
return json<VerifyPINActionData>(
{
errors: { general: errorMessage }
}, },
{ status: 500 } { status: 500 }
); );
@ -161,13 +256,12 @@ export const action = async ({
}; };
export default function VerifyExistingPIN() { export default function VerifyExistingPIN() {
const { phone, lastLoginAt } = useLoaderData<LoaderData>(); const { phone, deviceId, tokenData } = useLoaderData<LoaderData>();
const actionData = useActionData<VerifyPINActionData>(); const actionData = useActionData<VerifyPINActionData>();
const navigation = useNavigation(); const navigation = useNavigation();
const [pin, setPin] = useState(["", "", "", "", "", ""]); const [pin, setPin] = useState(["", "", "", "", "", ""]);
const [showPin, setShowPin] = useState(false); const [showPin, setShowPin] = useState(false);
const [attemptCount, setAttemptCount] = useState(0);
const pinRefs = useRef<(HTMLInputElement | null)[]>([]); const pinRefs = useRef<(HTMLInputElement | null)[]>([]);
@ -223,37 +317,8 @@ export default function VerifyExistingPIN() {
)} ${phoneNumber.substring(5, 9)} ${phoneNumber.substring(9)}`; )} ${phoneNumber.substring(5, 9)} ${phoneNumber.substring(9)}`;
}; };
// Format last login
const formatLastLogin = (dateString?: string) => {
if (!dateString) return "Belum pernah login";
const date = new Date(dateString);
const now = new Date();
const diffInHours = Math.floor(
(now.getTime() - date.getTime()) / (1000 * 60 * 60)
);
if (diffInHours < 1) return "Kurang dari 1 jam yang lalu";
if (diffInHours < 24) return `${diffInHours} jam yang lalu`;
const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays === 1) return "Kemarin";
if (diffInDays < 7) return `${diffInDays} hari yang lalu`;
return date.toLocaleDateString("id-ID", {
year: "numeric",
month: "long",
day: "numeric"
});
};
const fullPin = pin.join(""); const fullPin = pin.join("");
// Track failed attempts
if (actionData?.errors?.pin && attemptCount < 3) {
// In real implementation, this would be tracked server-side
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Progress Indicator */} {/* Progress Indicator */}
@ -268,7 +333,7 @@ export default function VerifyExistingPIN() {
Selamat Datang Kembali! Selamat Datang Kembali!
</p> </p>
<p className="text-sm text-green-700 dark:text-green-400"> <p className="text-sm text-green-700 dark:text-green-400">
Login terakhir: {formatLastLogin(lastLoginAt)} Langkah terakhir untuk mengakses dashboard
</p> </p>
</div> </div>
</div> </div>
@ -290,16 +355,17 @@ export default function VerifyExistingPIN() {
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{/* Error Alert */} {/* Error Alert */}
{actionData?.errors?.general && ( {(actionData?.errors?.general || actionData?.errors?.pin) && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<AlertDescription>{actionData.errors.general}</AlertDescription> <AlertDescription>
{actionData.errors.general || actionData.errors.pin}
</AlertDescription>
</Alert> </Alert>
)} )}
{/* Form */} {/* Form */}
<Form method="post" className="space-y-6"> <Form method="post" className="space-y-6">
<input type="hidden" name="phone" value={phone} />
<input type="hidden" name="pin" value={fullPin} /> <input type="hidden" name="pin" value={fullPin} />
{/* PIN Input */} {/* PIN Input */}
@ -342,19 +408,6 @@ export default function VerifyExistingPIN() {
))} ))}
</div> </div>
{actionData?.errors?.pin && (
<div className="text-center">
<p className="text-sm text-red-600 dark:text-red-400">
{actionData.errors.pin}
</p>
{attemptCount >= 2 && (
<p className="text-xs text-amber-600 dark:text-amber-400 mt-1">
Akun akan dikunci sementara setelah 3 kali percobaan gagal
</p>
)}
</div>
)}
<p className="text-center text-xs text-muted-foreground"> <p className="text-center text-xs text-muted-foreground">
Tempel PIN atau ketik manual Tempel PIN atau ketik manual
</p> </p>
@ -436,9 +489,7 @@ export default function VerifyExistingPIN() {
{/* Back Link */} {/* Back Link */}
<div className="text-center"> <div className="text-center">
<Link <Link
to={`/authpengelola/verifyotptologin?phone=${encodeURIComponent( to="/authpengelola/verifyotptologin"
phone
)}`}
className="inline-flex items-center text-sm text-muted-foreground hover:text-primary transition-colors" className="inline-flex items-center text-sm text-muted-foreground hover:text-primary transition-colors"
> >
<ArrowLeft className="mr-1 h-4 w-4" /> <ArrowLeft className="mr-1 h-4 w-4" />
@ -448,24 +499,6 @@ export default function VerifyExistingPIN() {
</CardContent> </CardContent>
</Card> </Card>
{/* Demo Info */}
<Card className="border border-green-200 dark:border-green-800 bg-green-50/50 dark:bg-green-950/20 backdrop-blur-sm">
<CardContent className="p-4">
<div className="text-center">
<p className="text-sm font-medium text-green-800 dark:text-green-300 mb-2">
Demo PIN:
</p>
<div className="text-xs text-green-700 dark:text-green-400 space-y-1">
<p>
Gunakan PIN:{" "}
<span className="font-mono font-bold text-lg">123456</span>
</p>
<p>Untuk testing login flow pengelola</p>
</div>
</div>
</CardContent>
</Card>
{/* Security Notice */} {/* Security Notice */}
<Card className="border border-amber-200 dark:border-amber-800 bg-amber-50/50 dark:bg-amber-950/20 backdrop-blur-sm"> <Card className="border border-amber-200 dark:border-amber-800 bg-amber-50/50 dark:bg-amber-950/20 backdrop-blur-sm">
<CardContent className="p-4"> <CardContent className="p-4">

View File

@ -28,6 +28,10 @@ import {
Smartphone, Smartphone,
Shield Shield
} from "lucide-react"; } from "lucide-react";
import { getSession, commitSession } from "~/sessions.server";
import { validateOtp } from "~/utils/auth-utils";
import pengelolaAuthService from "~/services/auth/pengelola.service";
import type { AuthTokenData } from "~/types/auth.types";
// Progress Indicator Component untuk Login (3 steps) // Progress Indicator Component untuk Login (3 steps)
const LoginProgressIndicator = ({ currentStep = 2, totalSteps = 3 }) => { const LoginProgressIndicator = ({ currentStep = 2, totalSteps = 3 }) => {
@ -73,6 +77,7 @@ const LoginProgressIndicator = ({ currentStep = 2, totalSteps = 3 }) => {
// Interfaces // Interfaces
interface LoaderData { interface LoaderData {
phone: string; phone: string;
deviceId: string;
otpSentAt: string; otpSentAt: string;
expiryMinutes: number; expiryMinutes: number;
} }
@ -90,16 +95,19 @@ interface VerifyOTPLoginActionData {
export const loader = async ({ export const loader = async ({
request request
}: LoaderFunctionArgs): Promise<Response> => { }: LoaderFunctionArgs): Promise<Response> => {
const url = new URL(request.url); const session = await getSession(request);
const phone = url.searchParams.get("phone"); const phone = session.get("tempLoginPhone");
const deviceId = session.get("tempLoginDeviceId");
const otpSentAt = session.get("tempLoginOtpSentAt");
if (!phone) { if (!phone || !deviceId) {
return redirect("/authpengelola/requestotpforlogin"); return redirect("/authpengelola/requestotpforlogin");
} }
return json<LoaderData>({ return json<LoaderData>({
phone, phone,
otpSentAt: new Date().toISOString(), deviceId,
otpSentAt: otpSentAt || new Date().toISOString(),
expiryMinutes: 5 expiryMinutes: 5
}); });
}; };
@ -109,42 +117,117 @@ export const action = async ({
}: ActionFunctionArgs): Promise<Response> => { }: ActionFunctionArgs): Promise<Response> => {
const formData = await request.formData(); const formData = await request.formData();
const otp = formData.get("otp") as string; const otp = formData.get("otp") as string;
const phone = formData.get("phone") as string;
const actionType = formData.get("_action") as string; const actionType = formData.get("_action") as string;
if (actionType === "resend") { const session = await getSession(request);
// Simulasi resend OTP const phone = session.get("tempLoginPhone");
console.log("Resending login OTP to WhatsApp:", phone); const deviceId = session.get("tempLoginDeviceId");
return json<VerifyOTPLoginActionData>({ if (!phone || !deviceId) {
return redirect("/authpengelola/requestotpforlogin");
}
if (actionType === "resend") {
try {
// Resend OTP
await pengelolaAuthService.requestOtpLogin({
phone,
role_name: "pengelola"
});
// Update OTP sent time
session.set("tempLoginOtpSentAt", new Date().toISOString());
return json<VerifyOTPLoginActionData>(
{
success: true, success: true,
message: "Kode OTP baru telah dikirim ke WhatsApp Anda", message: "Kode OTP baru telah dikirim ke WhatsApp Anda",
otpSentAt: new Date().toISOString() otpSentAt: new Date().toISOString()
}); },
{
headers: {
"Set-Cookie": await commitSession(session)
}
}
);
} catch (error: any) {
console.error("Resend OTP error:", error);
const errorMessage =
error.response?.data?.meta?.message ||
"Gagal mengirim ulang OTP. Silakan coba lagi.";
return json<VerifyOTPLoginActionData>(
{
errors: { general: errorMessage }
},
{ status: 500 }
);
}
} }
if (actionType === "verify") { if (actionType === "verify") {
// Validation // Validation
const errors: { otp?: string; general?: string } = {}; const errors: { otp?: string; general?: string } = {};
if (!otp || otp.length !== 4) { if (!otp) {
errors.otp = "Kode OTP harus 4 digit"; errors.otp = "Kode OTP wajib diisi";
} else if (!/^\d{4}$/.test(otp)) { } else if (!validateOtp(otp)) {
errors.otp = "Kode OTP hanya boleh berisi angka"; errors.otp = "Kode OTP harus 4 digit angka";
} }
if (Object.keys(errors).length > 0) { if (Object.keys(errors).length > 0) {
return json<VerifyOTPLoginActionData>({ errors }, { status: 400 }); return json<VerifyOTPLoginActionData>({ errors }, { status: 400 });
} }
// Simulasi verifikasi OTP - dalam implementasi nyata, cek ke database/cache try {
if (otp === "1234") { // Verify OTP untuk login
// OTP valid, lanjut ke verifikasi PIN const response = await pengelolaAuthService.verifyOtpLogin({
return redirect( phone,
`/authpengelola/verifyexistingpin?phone=${encodeURIComponent(phone)}` otp,
device_id: deviceId,
role_name: "pengelola"
});
const tokenData = response.data;
// Check if tokenData exists
if (!tokenData) {
return json<VerifyOTPLoginActionData>(
{
errors: { general: "Response data tidak valid dari server" }
},
{ status: 500 }
); );
} }
// Simpan data token ke session
session.set("tempLoginTokenData", tokenData);
session.set("tempLoginPhone", phone);
session.set("tempLoginDeviceId", deviceId);
// Check next step dari response
if (tokenData.next_step === "verif_pin") {
// Lanjut ke verifikasi PIN
return redirect("/authpengelola/verifyexistingpin", {
headers: {
"Set-Cookie": await commitSession(session)
}
});
} else {
// Jika sudah complete, langsung ke dashboard
// (tidak seharusnya terjadi untuk login flow, tapi handle just in case)
return redirect("/pengelola/dashboard", {
headers: {
"Set-Cookie": await commitSession(session)
}
});
}
} catch (error: any) {
console.error("Verify OTP login error:", error);
// Handle specific API errors
if (error.response?.status === 401) {
return json<VerifyOTPLoginActionData>( return json<VerifyOTPLoginActionData>(
{ {
errors: { otp: "Kode OTP tidak valid atau sudah kedaluwarsa" } errors: { otp: "Kode OTP tidak valid atau sudah kedaluwarsa" }
@ -153,6 +236,31 @@ export const action = async ({
); );
} }
if (error.response?.status === 429) {
return json<VerifyOTPLoginActionData>(
{
errors: {
otp: "Terlalu banyak percobaan. Silakan tunggu beberapa menit."
}
},
{ status: 429 }
);
}
// General error
const errorMessage =
error.response?.data?.meta?.message ||
"Gagal memverifikasi OTP. Silakan coba lagi.";
return json<VerifyOTPLoginActionData>(
{
errors: { general: errorMessage }
},
{ status: 500 }
);
}
}
return json<VerifyOTPLoginActionData>( return json<VerifyOTPLoginActionData>(
{ {
errors: { general: "Aksi tidak valid" } errors: { general: "Aksi tidak valid" }
@ -162,7 +270,8 @@ export const action = async ({
}; };
export default function VerifyOTPToLogin() { export default function VerifyOTPToLogin() {
const { phone, otpSentAt, expiryMinutes } = useLoaderData<LoaderData>(); const { phone, deviceId, otpSentAt, expiryMinutes } =
useLoaderData<LoaderData>();
const actionData = useActionData<VerifyOTPLoginActionData>(); const actionData = useActionData<VerifyOTPLoginActionData>();
const navigation = useNavigation(); const navigation = useNavigation();
@ -290,16 +399,17 @@ export default function VerifyOTPToLogin() {
)} )}
{/* Error Alert */} {/* Error Alert */}
{actionData?.errors?.otp && ( {(actionData?.errors?.otp || actionData?.errors?.general) && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<AlertDescription>{actionData.errors.otp}</AlertDescription> <AlertDescription>
{actionData.errors.otp || actionData.errors.general}
</AlertDescription>
</Alert> </Alert>
)} )}
{/* OTP Input Form */} {/* OTP Input Form */}
<Form method="post"> <Form method="post">
<input type="hidden" name="phone" value={phone} />
<input type="hidden" name="_action" value="verify" /> <input type="hidden" name="_action" value="verify" />
<input type="hidden" name="otp" value={otp.join("")} /> <input type="hidden" name="otp" value={otp.join("")} />
@ -376,7 +486,6 @@ export default function VerifyOTPToLogin() {
Tidak menerima kode? Tidak menerima kode?
</p> </p>
<Form method="post" className="inline"> <Form method="post" className="inline">
<input type="hidden" name="phone" value={phone} />
<input type="hidden" name="_action" value="resend" /> <input type="hidden" name="_action" value="resend" />
<Button <Button
type="submit" type="submit"
@ -429,20 +538,19 @@ export default function VerifyOTPToLogin() {
</CardContent> </CardContent>
</Card> </Card>
{/* Demo Info */} {/* Help Card */}
<Card className="border border-green-200 dark:border-green-800 bg-green-50/50 dark:bg-green-950/20 backdrop-blur-sm"> <Card className="border border-border bg-background/60 backdrop-blur-sm">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="text-center"> <div className="text-center">
<p className="text-sm font-medium text-green-800 dark:text-green-300 mb-2"> <p className="text-sm text-muted-foreground mb-2">Butuh bantuan?</p>
Demo OTP: <a
</p> href={`https://wa.me/6281234567890?text=Halo%20saya%20butuh%20bantuan%20login%20dengan%20nomor%20${phone}`}
<div className="text-xs text-green-700 dark:text-green-400 space-y-1"> target="_blank"
<p> rel="noopener noreferrer"
Gunakan kode:{" "} className="text-sm text-primary hover:text-primary/80 font-medium"
<span className="font-mono font-bold text-lg">1234</span> >
</p> Hubungi Customer Support
<p>Atau tunggu countdown habis untuk test resend</p> </a>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -1,25 +1,35 @@
import { json } from "@remix-run/node"; import { json, LoaderFunctionArgs } from "@remix-run/node";
import { Outlet, useLoaderData } from "@remix-run/react"; import { Outlet, useLoaderData } from "@remix-run/react";
import { PengelolaLayoutWrapper } from "~/components/layoutpengelola/layout-wrapper"; import { PengelolaLayoutWrapper } from "~/components/layoutpengelola/layout-wrapper";
import { requireUserSession, SessionData } from "~/sessions.server";
export const loader = async () => { interface LoaderData {
// Data untuk layout bisa diambil di sini user: SessionData;
return json({ }
user: {
name: "Fahmi Kurniawan", export async function loader({ request }: LoaderFunctionArgs) {
email: "pengelola@example.com", const userSession = await requireUserSession(
role: "Pengelola" request,
} "pengelola",
"complete"
);
return json<LoaderData>({
user: userSession
}); });
}; }
// pada kode app/routes/pengelola.tsx pada bagian:
export default function PengelolaPanelLayout() { export default function PengelolaPanelLayout() {
const { user } = useLoaderData<typeof loader>(); const { user } = useLoaderData<LoaderData>();
return ( return (
<PengelolaLayoutWrapper> <PengelolaLayoutWrapper user={user}>
{/* Outlet akan merender child routes */}
<Outlet /> <Outlet />
</PengelolaLayoutWrapper> </PengelolaLayoutWrapper>
); );
} }
/* terdapat error ini: Type '{ children: Element; user: SessionData; }' is not assignable to type 'IntrinsicAttributes & PengelolaLayoutWrapperProps'.
Property 'user' does not exist on type 'IntrinsicAttributes & PengelolaLayoutWrapperProps'. */

View File

@ -32,9 +32,15 @@ import {
import { Boxes } from "~/components/ui/background-boxes"; import { Boxes } from "~/components/ui/background-boxes";
import { ThemeFloatingDock } from "~/components/ui/floatingthemeswitch"; import { ThemeFloatingDock } from "~/components/ui/floatingthemeswitch";
// ✅ Import services and utils
import adminAuthService from "~/services/auth/admin.service";
import { validateOtp } from "~/utils/auth-utils";
import { createUserSession, getUserSession } from "~/sessions.server";
interface LoaderData { interface LoaderData {
email: string; email: string;
otpSentAt: string; deviceId: string;
remainingTime: string;
expiryMinutes: number; expiryMinutes: number;
} }
@ -48,64 +54,143 @@ interface OTPActionData {
}; };
} }
// ✅ Proper loader with URL params validation
export const loader = async ({ export const loader = async ({
request request
}: LoaderFunctionArgs): Promise<Response> => { }: LoaderFunctionArgs): Promise<Response> => {
const userSession = await getUserSession(request);
// Redirect if already logged in
if (userSession && userSession.role === "administrator") {
return redirect("/sys-rijig-adminpanel/dashboard");
}
const url = new URL(request.url); const url = new URL(request.url);
const email = url.searchParams.get("email"); const email = url.searchParams.get("email");
if (!email) { const deviceId = url.searchParams.get("device_id");
const remainingTime = url.searchParams.get("remaining_time");
if (!email || !deviceId) {
return redirect("/sys-rijig-administrator/sign-infirst"); return redirect("/sys-rijig-administrator/sign-infirst");
} }
return json<LoaderData>({ return json<LoaderData>({
email, email,
otpSentAt: new Date().toISOString(), deviceId,
remainingTime: remainingTime || "5:00",
expiryMinutes: 5 expiryMinutes: 5
}); });
}; };
// ✅ Action integrated with API service
export const action = async ({ export const action = async ({
request request
}: ActionFunctionArgs): Promise<Response> => { }: ActionFunctionArgs): Promise<Response> => {
const formData = await request.formData(); const formData = await request.formData();
const otp = formData.get("otp") as string; const otp = formData.get("otp") as string;
const email = formData.get("email") as string; const email = formData.get("email") as string;
const deviceId = formData.get("device_id") as string;
const action = formData.get("_action") as string; const action = formData.get("_action") as string;
if (action === "resend") { if (action === "resend") {
console.log("Resending OTP to:", email); try {
// ✅ Call login API again to resend OTP
const response = await adminAuthService.login({
device_id: deviceId,
email,
password: "temp" // We don't have password here, but API might handle resend differently
});
return json<OTPActionData>({ return json<OTPActionData>({
success: true, success: true,
message: "Kode OTP baru telah dikirim ke email Anda", message: "Kode OTP baru telah dikirim ke email Anda",
otpSentAt: new Date().toISOString() otpSentAt: new Date().toISOString()
}); });
} catch (error: any) {
console.error("Resend OTP error:", error);
return json<OTPActionData>(
{
errors: { general: "Gagal mengirim ulang OTP. Silakan coba lagi." },
success: false
},
{ status: 500 }
);
}
} }
if (action === "verify") { if (action === "verify") {
// ✅ Validation using utils
const errors: { otp?: string; general?: string } = {}; const errors: { otp?: string; general?: string } = {};
if (!otp || otp.length !== 4) { if (!otp) {
errors.otp = "Kode OTP harus 4 digit"; errors.otp = "Kode OTP wajib diisi";
} else if (!/^\d{4}$/.test(otp)) { } else if (!validateOtp(otp)) {
errors.otp = "Kode OTP hanya boleh berisi angka"; errors.otp = "Kode OTP harus 4 digit angka";
}
if (!email || !deviceId) {
errors.general = "Data session tidak valid. Silakan login ulang.";
} }
if (Object.keys(errors).length > 0) { if (Object.keys(errors).length > 0) {
return json<OTPActionData>({ errors, success: false }, { status: 400 }); return json<OTPActionData>({ errors, success: false }, { status: 400 });
} }
if (otp === "1234") { try {
return redirect("/sys-rijig-adminpanel/dashboard"); // ✅ Call API service for OTP verification
const response = await adminAuthService.verifyOtp({
device_id: deviceId,
email,
otp
});
if (response.meta.status === 200 && response.data) {
// ✅ Create user session after successful verification
return createUserSession({
request,
sessionData: {
accessToken: response.data.access_token,
refreshToken: response.data.refresh_token,
sessionId: response.data.session_id,
role: "administrator",
deviceId,
email,
registrationStatus: response.data.registration_status || "complete",
nextStep: response.data.next_step || "completed"
},
redirectTo: "/sys-rijig-adminpanel/dashboard"
});
} }
return json<OTPActionData>( return json<OTPActionData>(
{ {
errors: { otp: "Kode OTP tidak valid atau sudah kedaluwarsa" }, errors: { otp: "Verifikasi OTP gagal" },
success: false success: false
}, },
{ status: 401 } { status: 401 }
); );
} catch (error: any) {
console.error("OTP verification error:", error);
// ✅ Handle specific API errors
if (error.response?.data?.meta?.message) {
return json<OTPActionData>(
{
errors: { otp: error.response.data.meta.message },
success: false
},
{ status: error.response.status || 401 }
);
}
return json<OTPActionData>(
{
errors: { general: "Terjadi kesalahan server. Silakan coba lagi." },
success: false
},
{ status: 500 }
);
}
} }
return json<OTPActionData>( return json<OTPActionData>(
@ -118,13 +203,18 @@ export const action = async ({
}; };
export default function AdminVerifyOTP() { export default function AdminVerifyOTP() {
const { email, otpSentAt, expiryMinutes } = useLoaderData<LoaderData>(); const { email, deviceId, remainingTime, expiryMinutes } =
useLoaderData<LoaderData>();
const actionData = useActionData<OTPActionData>(); const actionData = useActionData<OTPActionData>();
const navigation = useNavigation(); const navigation = useNavigation();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [otp, setOtp] = useState(["", "", "", ""]); const [otp, setOtp] = useState(["", "", "", ""]);
const [timeLeft, setTimeLeft] = useState(expiryMinutes * 60); const [timeLeft, setTimeLeft] = useState(() => {
// ✅ Parse remaining time from API response
const [minutes, seconds] = remainingTime.split(":").map(Number);
return minutes * 60 + (seconds || 0);
});
const [canResend, setCanResend] = useState(false); const [canResend, setCanResend] = useState(false);
const inputRefs = useRef<(HTMLInputElement | null)[]>([]); const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
@ -135,6 +225,11 @@ export default function AdminVerifyOTP() {
// Timer countdown // Timer countdown
useEffect(() => { useEffect(() => {
if (timeLeft <= 0) {
setCanResend(true);
return;
}
const timer = setInterval(() => { const timer = setInterval(() => {
setTimeLeft((prev) => { setTimeLeft((prev) => {
if (prev <= 1) { if (prev <= 1) {
@ -146,7 +241,7 @@ export default function AdminVerifyOTP() {
}, 1000); }, 1000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, []); }, [timeLeft]);
// Reset timer when OTP is resent // Reset timer when OTP is resent
useEffect(() => { useEffect(() => {
@ -243,9 +338,20 @@ export default function AdminVerifyOTP() {
</Alert> </Alert>
)} )}
{/* General Error Alert */}
{actionData?.errors?.general && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{actionData.errors.general}
</AlertDescription>
</Alert>
)}
{/* OTP Input Form */} {/* OTP Input Form */}
<Form method="post"> <Form method="post">
<input type="hidden" name="email" value={email} /> <input type="hidden" name="email" value={email} />
<input type="hidden" name="device_id" value={deviceId} />
<input type="hidden" name="_action" value="verify" /> <input type="hidden" name="_action" value="verify" />
<input type="hidden" name="otp" value={otp.join("")} /> <input type="hidden" name="otp" value={otp.join("")} />
@ -328,6 +434,7 @@ export default function AdminVerifyOTP() {
</p> </p>
<Form method="post" className="inline"> <Form method="post" className="inline">
<input type="hidden" name="email" value={email} /> <input type="hidden" name="email" value={email} />
<input type="hidden" name="device_id" value={deviceId} />
<input type="hidden" name="_action" value="resend" /> <input type="hidden" name="_action" value="resend" />
<Button <Button
type="submit" type="submit"
@ -367,22 +474,6 @@ export default function AdminVerifyOTP() {
</div> </div>
</div> </div>
{/* Demo Info */}
<div className="p-4 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 rounded-lg">
<p className="text-sm font-medium text-amber-800 dark:text-amber-300 mb-2">
Demo OTP:
</p>
<div className="text-xs text-amber-700 dark:text-amber-400 space-y-1">
<p>
Gunakan kode:{" "}
<span className="font-mono font-bold text-lg px-2 py-1 bg-amber-100 dark:bg-amber-900/50 rounded">
1234
</span>
</p>
<p>Atau tunggu countdown habis untuk test resend</p>
</div>
</div>
{/* Back to Login */} {/* Back to Login */}
<div className="text-center"> <div className="text-center">
<Link <Link

View File

@ -5,7 +5,7 @@ import {
type LoaderFunctionArgs type LoaderFunctionArgs
} from "@remix-run/node"; } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react"; import { Form, useActionData, useNavigation } from "@remix-run/react";
import { useState, useEffect } from "react"; import { useState } from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card"; import { Card, CardContent } from "~/components/ui/card";
@ -25,28 +25,42 @@ import {
import { Boxes } from "~/components/ui/background-boxes"; import { Boxes } from "~/components/ui/background-boxes";
import { ThemeFloatingDock } from "~/components/ui/floatingthemeswitch"; import { ThemeFloatingDock } from "~/components/ui/floatingthemeswitch";
// Interface untuk action response // ✅ Import services and utils
import adminAuthService from "~/services/auth/admin.service";
import {
generateDeviceId,
validateEmail,
validatePassword
} from "~/utils/auth-utils";
import { getUserSession } from "~/sessions.server";
interface LoginActionData { interface LoginActionData {
errors?: { errors?: {
email?: string; email?: string;
password?: string; password?: string;
general?: string; general?: string;
}; };
success: boolean; success?: boolean;
otpData?: {
email: string;
message: string;
remaining_time: string;
};
} }
// Loader - cek apakah user sudah login // ✅ Proper loader with session check
export const loader = async ({ request }: LoaderFunctionArgs) => { export const loader = async ({ request }: LoaderFunctionArgs) => {
// Dalam implementasi nyata, cek session/cookie const userSession = await getUserSession(request);
// const session = await getSession(request.headers.get("Cookie"));
// if (session.has("adminId")) { // Redirect if already logged in
// return redirect("/admin/dashboard"); if (userSession && userSession.role === "administrator") {
// } return redirect("/sys-rijig-adminpanel/dashboard");
}
return json({}); return json({});
}; };
// Action untuk handle login // ✅ Action integrated with API service
export const action = async ({ export const action = async ({
request request
}: ActionFunctionArgs): Promise<Response> => { }: ActionFunctionArgs): Promise<Response> => {
@ -55,48 +69,79 @@ export const action = async ({
const password = formData.get("password") as string; const password = formData.get("password") as string;
const remember = formData.get("remember") === "on"; const remember = formData.get("remember") === "on";
// Validation // Validation using utils
const errors: { email?: string; password?: string; general?: string } = {}; const errors: { email?: string; password?: string; general?: string } = {};
if (!email) { if (!email) {
errors.email = "Email wajib diisi"; errors.email = "Email wajib diisi";
} else if (!/\S+@\S+\.\S+/.test(email)) { } else if (!validateEmail(email)) {
errors.email = "Format email tidak valid"; errors.email = "Format email tidak valid";
} }
if (!password) { if (!password) {
errors.password = "Password wajib diisi"; errors.password = "Password wajib diisi";
} else if (password.length < 6) { } else if (!validatePassword(password)) {
errors.password = "Password minimal 6 karakter"; errors.password =
"Password harus minimal 8 karakter, mengandung huruf kapital, angka, dan simbol";
} }
if (Object.keys(errors).length > 0) { if (Object.keys(errors).length > 0) {
return json<LoginActionData>({ errors, success: false }, { status: 400 }); return json<LoginActionData>({ errors, success: false }, { status: 400 });
} }
// Simulasi autentikasi - dalam implementasi nyata, cek ke database try {
if (email === "admin@wastemanagement.com" && password === "admin123") { // ✅ Generate device ID
// Set session dan redirect const deviceId = generateDeviceId("Admin");
// const session = await getSession(request.headers.get("Cookie"));
// session.set("adminId", "admin-001"); // ✅ Call API service
// session.set("adminName", "Administrator"); const response = await adminAuthService.login({
// session.set("adminEmail", email); device_id: deviceId,
email,
password
});
if (response.meta.status === 200 && response.data) {
// ✅ Success - redirect to OTP verification with data
const searchParams = new URLSearchParams({
email: response.data.email || email,
device_id: deviceId,
remaining_time: response.data.remaining_time || "5:00"
});
// Redirect ke OTP verification
return redirect( return redirect(
`/sys-rijig-administator/emailotpverifyrequired?email=${encodeURIComponent( `/sys-rijig-administrator/emailotpverifyrequired?${searchParams.toString()}`
email
)}`
); );
} }
return json<LoginActionData>( return json<LoginActionData>(
{ {
errors: { general: "Email atau password salah" }, errors: { general: "Login gagal. Periksa email dan password Anda." },
success: false success: false
}, },
{ status: 401 } { status: 401 }
); );
} catch (error: any) {
console.error("Login error:", error);
// ✅ Handle API errors
if (error.response?.data?.meta?.message) {
return json<LoginActionData>(
{
errors: { general: error.response.data.meta.message },
success: false
},
{ status: error.response.status || 500 }
);
}
return json<LoginActionData>(
{
errors: { general: "Terjadi kesalahan server. Silakan coba lagi." },
success: false
},
{ status: 500 }
);
}
}; };
export default function AdminLogin() { export default function AdminLogin() {
@ -182,7 +227,7 @@ export default function AdminLogin() {
Password Password
</Label> </Label>
<a <a
href="/admin/forgot-password" href="/sys-rijig-administrator/forgot-password"
className="text-sm text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-300 underline-offset-2 hover:underline transition-colors" className="text-sm text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-300 underline-offset-2 hover:underline transition-colors"
> >
Lupa password? Lupa password?
@ -253,14 +298,17 @@ export default function AdminLogin() {
)} )}
</Button> </Button>
{/* Demo Credentials */} {/* ✅ Updated demo credentials */}
<div className="p-4 bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 rounded-lg"> <div className="p-4 bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 rounded-lg">
<p className="text-sm font-medium text-blue-800 dark:text-blue-300 mb-2"> <p className="text-sm font-medium text-blue-800 dark:text-blue-300 mb-2">
Demo Credentials: Demo Credentials:
</p> </p>
<div className="text-xs text-blue-700 dark:text-blue-400 space-y-1"> <div className="text-xs text-blue-700 dark:text-blue-400 space-y-1">
<p>Email: admin@wastemanagement.com</p> <p>Email: pahmilucu123@gmail.com</p>
<p>Password: admin123</p> <p>Password: Halo12345,</p>
<p className="text-amber-600 dark:text-amber-400 font-medium">
OTP akan dikirim ke email setelah login
</p>
</div> </div>
</div> </div>
</Form> </Form>

View File

@ -0,0 +1,74 @@
import apiClient, { ApiResponse } from "~/lib/api-client";
import type {
AdminLoginRequest,
AdminOtpVerifyRequest,
AdminRegisterRequest,
AuthTokenData,
ForgotPasswordRequest,
OtpResponse,
ResetPasswordRequest,
VerifyEmailRequest
} from "~/types/auth.types";
class AdminAuthService {
async login(data: AdminLoginRequest): Promise<ApiResponse<OtpResponse>> {
const response = await apiClient.post<ApiResponse<OtpResponse>>(
"/auth/login/admin",
data
);
return response.data;
}
async verifyOtp(
data: AdminOtpVerifyRequest
): Promise<ApiResponse<AuthTokenData>> {
const response = await apiClient.post<ApiResponse<AuthTokenData>>(
"/auth/verify-otp-admin",
data
);
return response.data;
}
async register(data: AdminRegisterRequest): Promise<
ApiResponse<{
message: string;
email: string;
expires_in_seconds: number;
remaining_time: string;
}>
> {
const response = await apiClient.post<ApiResponse>(
"/auth/register/admin",
data
);
return response.data;
}
async forgotPassword(
data: ForgotPasswordRequest
): Promise<ApiResponse<OtpResponse>> {
const response = await apiClient.post<ApiResponse<OtpResponse>>(
"/auth/forgot-password",
data
);
return response.data;
}
async resetPassword(data: ResetPasswordRequest): Promise<ApiResponse> {
const response = await apiClient.post<ApiResponse>(
"/auth/reset-password",
data
);
return response.data;
}
async verifyEmail(data: VerifyEmailRequest): Promise<ApiResponse> {
const response = await apiClient.post<ApiResponse>(
"/auth/verify-email",
data
);
return response.data;
}
}
export default new AdminAuthService();

View File

@ -0,0 +1,29 @@
import apiClient, { ApiResponse } from "~/lib/api-client";
import type { AuthTokenData, RefreshTokenRequest } from "~/types/auth.types";
class CommonAuthService {
async refreshToken(
data: RefreshTokenRequest
): Promise<ApiResponse<AuthTokenData>> {
const response = await apiClient.post<ApiResponse<AuthTokenData>>(
"/auth/refresh-token",
data
);
return response.data;
}
async logout(): Promise<ApiResponse> {
const response = await apiClient.post<ApiResponse>("/auth/logout");
return response.data;
}
setAuthToken(token: string) {
apiClient.defaults.headers.common["Authorization"] = `Bearer ${token}`;
}
removeAuthToken() {
delete apiClient.defaults.headers.common["Authorization"];
}
}
export default new CommonAuthService();

View File

@ -0,0 +1,100 @@
import apiClient, { ApiResponse } from "~/lib/api-client";
import type {
ApprovalCheckResponse,
AuthTokenData,
CompanyProfileRequest,
CreatePinRequest,
PengelolaOtpRequest,
PengelolaOtpVerifyRequest,
VerifyPinRequest
} from "~/types/auth.types";
class PengelolaAuthService {
async requestOtpRegister(data: PengelolaOtpRequest): Promise<ApiResponse> {
const response = await apiClient.post<ApiResponse>(
"/auth/request-otp/register",
data
);
return response.data;
}
async requestOtpLogin(data: PengelolaOtpRequest): Promise<ApiResponse> {
const response = await apiClient.post<ApiResponse>(
"/auth/request-otp",
data
);
return response.data;
}
async verifyOtpRegister(
data: PengelolaOtpVerifyRequest
): Promise<ApiResponse<AuthTokenData>> {
const response = await apiClient.post<ApiResponse<AuthTokenData>>(
"/auth/verif-otp/register",
data
);
return response.data;
}
async verifyOtpLogin(
data: PengelolaOtpVerifyRequest
): Promise<ApiResponse<AuthTokenData>> {
const response = await apiClient.post<ApiResponse<AuthTokenData>>(
"/auth/verif-otp",
data
);
return response.data;
}
async createCompanyProfile(
data: CompanyProfileRequest
): Promise<ApiResponse<AuthTokenData>> {
const formData = new FormData();
Object.entries(data).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
if (value instanceof File) {
formData.append(key, value);
} else {
formData.append(key, String(value));
}
}
});
const response = await apiClient.post<ApiResponse<AuthTokenData>>(
"/companyprofile/create",
formData,
{
headers: {
"Content-Type": "multipart/form-data"
}
}
);
return response.data;
}
async checkApproval(): Promise<ApiResponse<ApprovalCheckResponse>> {
const response = await apiClient.get<ApiResponse<ApprovalCheckResponse>>(
"/auth/cekapproval"
);
return response.data;
}
async createPin(data: CreatePinRequest): Promise<ApiResponse<AuthTokenData>> {
const response = await apiClient.post<ApiResponse<AuthTokenData>>(
"/pin/create",
data
);
return response.data;
}
async verifyPin(data: VerifyPinRequest): Promise<ApiResponse<AuthTokenData>> {
const response = await apiClient.post<ApiResponse<AuthTokenData>>(
"/pin/verif",
data
);
return response.data;
}
}
export default new PengelolaAuthService();

View File

@ -1,5 +1,26 @@
import {createThemeSessionResolver} from 'remix-themes' import {createThemeSessionResolver} from 'remix-themes'
import { createCookieSessionStorage } from "@remix-run/node" import { createCookieSessionStorage, redirect } from "@remix-run/node"
import type { UserRole, RegistrationStatus, TokenType } from "~/types/auth.types";
import commonAuthService from "~/services/auth/common.service";
export interface SessionData {
accessToken: string;
refreshToken: string;
sessionId: string;
role: UserRole;
deviceId?: string;
email?: string;
phone?: string;
tokenType?: TokenType;
registrationStatus?: RegistrationStatus;
nextStep?: string;
}
// Session flash data
export interface SessionFlashData {
error?: string;
success?: string;
}
const sessionStorage = createCookieSessionStorage({ const sessionStorage = createCookieSessionStorage({
cookie: { cookie: {
@ -8,9 +29,139 @@ const sessionStorage = createCookieSessionStorage({
path: '/', path: '/',
httpOnly: true, httpOnly: true,
sameSite: 'lax', sameSite: 'lax',
secrets: ['s3cr3t'], // secrets: ['s3cr3t'],
secrets: [process.env.SESSION_SECRET || "s3cr3t"],
secure: process.env.NODE_ENV === "production",
// secure: true, // secure: true,
}, },
}) })
export async function getSession(request: Request) {
const cookie = request.headers.get("Cookie");
return sessionStorage.getSession(cookie);
}
// Commit session
export async function commitSession(session: any) {
return sessionStorage.commitSession(session);
}
// Destroy session
export async function destroySession(session: any) {
return sessionStorage.destroySession(session);
}
// Create user session
export async function createUserSession({
request,
sessionData,
redirectTo,
}: {
request: Request;
sessionData: SessionData;
redirectTo: string;
}) {
const session = await getSession(request);
// Set all session data
Object.entries(sessionData).forEach(([key, value]) => {
if (value !== undefined) {
session.set(key as keyof SessionData, value);
}
});
// Set auth token for API client (server-side)
commonAuthService.setAuthToken(sessionData.accessToken);
return redirect(redirectTo, {
headers: {
"Set-Cookie": await commitSession(session),
},
});
}
// Get user from session
export async function getUserSession(request: Request): Promise<SessionData | null> {
const session = await getSession(request);
const accessToken = session.get("accessToken");
if (!accessToken) return null;
// Set auth token for API client
commonAuthService.setAuthToken(accessToken);
return {
accessToken: session.get("accessToken") || "",
refreshToken: session.get("refreshToken") || "",
sessionId: session.get("sessionId") || "",
role: session.get("role") || "pengelola",
deviceId: session.get("deviceId"),
email: session.get("email"),
phone: session.get("phone"),
tokenType: session.get("tokenType"),
registrationStatus: session.get("registrationStatus"),
nextStep: session.get("nextStep"),
};
}
// Require user session (for protected routes)
export async function requireUserSession(
request: Request,
role?: UserRole,
requiredStatus?: RegistrationStatus
) {
const userSession = await getUserSession(request);
if (!userSession) {
throw redirect("/");
}
// Check role if specified
if (role && userSession.role !== role) {
throw redirect("/");
}
// Check registration status if specified
if (requiredStatus && userSession.registrationStatus !== requiredStatus) {
// Redirect based on current status and role
if (userSession.role === "pengelola") {
switch (userSession.registrationStatus) {
case "uncomplete":
throw redirect("/authpengelola/completingcompanyprofile");
case "awaiting_approval":
throw redirect("/authpengelola/waitingapprovalfromadministrator");
case "approved":
throw redirect("/authpengelola/createanewpin");
default:
break;
}
}
}
return userSession;
}
// Logout user
export async function logout(request: Request) {
const session = await getSession(request);
try {
// Call logout API
await commonAuthService.logout();
} catch (error) {
// Continue logout even if API fails
console.error("Logout API error:", error);
}
// Clear auth token
commonAuthService.removeAuthToken();
return redirect("/", {
headers: {
"Set-Cookie": await destroySession(session),
},
});
}
export const themeSessionResolver = createThemeSessionResolver(sessionStorage) export const themeSessionResolver = createThemeSessionResolver(sessionStorage)

113
app/types/auth.types.ts Normal file
View File

@ -0,0 +1,113 @@
export type UserRole = "administrator" | "pengelola";
export type RegistrationStatus =
| "uncomplete"
| "awaiting_approval"
| "approved"
| "complete";
export type TokenType = "partial" | "full";
export interface AuthTokenData {
message: string;
access_token: string;
refresh_token: string;
token_type?: TokenType;
expires_in?: number;
registration_status?: RegistrationStatus;
next_step?: string;
session_id: string;
}
export interface AdminLoginRequest {
device_id: string;
email: string;
password: string;
}
export interface AdminOtpVerifyRequest {
device_id: string;
email: string;
otp: string;
}
export interface AdminRegisterRequest {
name: string;
gender: "laki-laki" | "perempuan";
dateofbirth: string;
placeofbirth: string;
phone: string;
email: string;
password: string;
password_confirm: string;
}
export interface ForgotPasswordRequest {
email: string;
}
export interface ResetPasswordRequest {
token: string;
email: string;
new_password: string;
}
export interface VerifyEmailRequest {
email: string;
token: string;
}
export interface PengelolaOtpRequest {
phone: string;
role_name: "pengelola";
}
export interface PengelolaOtpVerifyRequest {
phone: string;
otp: string;
device_id: string;
role_name: "pengelola";
}
export interface CompanyProfileRequest {
companyname: string;
companyaddress: string;
companyphone: string;
companyemail: string;
companywebsite: string;
taxid: string;
foundeddate: string;
companytype: string;
companydescription: string;
company_logo?: File;
}
export interface CreatePinRequest {
userpin: string;
}
export interface VerifyPinRequest {
userpin: string;
}
export interface RefreshTokenRequest {
refresh_token: string;
}
export interface OtpResponse {
message: string;
email?: string;
expires_in_seconds: number;
remaining_time: string;
can_resend?: boolean;
max_attempts?: number;
}
export interface ApprovalCheckResponse {
message: string;
registration_status: RegistrationStatus;
next_step: string;
access_token?: string;
refresh_token?: string;
token_type?: TokenType;
expires_in?: number;
session_id?: string;
}

82
app/utils/auth-utils.ts Normal file
View File

@ -0,0 +1,82 @@
export function generateDeviceId(prefix: string = ""): string {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 15);
const deviceId = `${prefix}${timestamp}${random}`;
return Buffer.from(deviceId).toString("base64");
}
export function validateEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
export function validatePassword(password: string): boolean {
const passwordRegex =
/^(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()\-_=+[\]{}|;:'",.<>?/`~]).{8,}$/;
return passwordRegex.test(password);
}
export function validatePhoneNumber(phone: string): boolean {
const phoneRegex = /^62\d{8,14}$/;
return phoneRegex.test(phone);
}
export function validatePin(pin: string): boolean {
const pinRegex = /^\d{6}$/;
return pinRegex.test(pin);
}
export function validateOtp(otp: string): boolean {
const otpRegex = /^\d{4}$/;
return otpRegex.test(otp);
}
export function formatDateToDDMMYYYY(date: Date): string {
const day = date.getDate().toString().padStart(2, "0");
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const year = date.getFullYear();
return `${day}-${month}-${year}`;
}
export function parseDateFromDDMMYYYY(dateString: string): Date | null {
const parts = dateString.split("-");
if (parts.length !== 3) return null;
const day = parseInt(parts[0], 10);
const month = parseInt(parts[1], 10) - 1;
const year = parseInt(parts[2], 10);
const date = new Date(year, month, day);
if (
date.getDate() !== day ||
date.getMonth() !== month ||
date.getFullYear() !== year
) {
return null;
}
return date;
}
export function getRemainingTime(seconds: number): string {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
}
export function extractResetTokenFromUrl(
url: string
): { token: string; email: string } | null {
try {
const urlObj = new URL(url);
const token = urlObj.searchParams.get("token");
const email = urlObj.searchParams.get("email");
if (!token || !email) return null;
return { token, email };
} catch {
return null;
}
}

82
package-lock.json generated
View File

@ -24,6 +24,7 @@
"@remix-run/react": "^2.16.8", "@remix-run/react": "^2.16.8",
"@remix-run/serve": "^2.16.8", "@remix-run/serve": "^2.16.8",
"@tabler/icons-react": "^3.34.0", "@tabler/icons-react": "^3.34.0",
"axios": "^1.10.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"isbot": "^4.1.0", "isbot": "^4.1.0",
@ -4699,6 +4700,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/autoprefixer": { "node_modules/autoprefixer": {
"version": "10.4.21", "version": "10.4.21",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
@ -4762,6 +4769,17 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/axios": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/axobject-query": { "node_modules/axobject-query": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@ -5317,6 +5335,18 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/comma-separated-tokens": { "node_modules/comma-separated-tokens": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
@ -5699,6 +5729,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@ -6051,7 +6090,6 @@
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
@ -7144,6 +7182,26 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": { "node_modules/for-each": {
"version": "0.3.5", "version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@ -7187,6 +7245,22 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/form-data": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz",
"integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/format": { "node_modules/format": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
@ -11309,6 +11383,12 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/pump": { "node_modules/pump": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/pump/-/pump-2.0.1.tgz", "resolved": "https://registry.npmjs.org/pump/-/pump-2.0.1.tgz",

View File

@ -30,6 +30,7 @@
"@remix-run/react": "^2.16.8", "@remix-run/react": "^2.16.8",
"@remix-run/serve": "^2.16.8", "@remix-run/serve": "^2.16.8",
"@tabler/icons-react": "^3.34.0", "@tabler/icons-react": "^3.34.0",
"axios": "^1.10.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"isbot": "^4.1.0", "isbot": "^4.1.0",