feat: auth for admin and pengelola
This commit is contained in:
parent
83729c9950
commit
83942347f5
|
@ -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 />
|
||||||
|
|
|
@ -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 */}
|
||||||
|
|
|
@ -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;
|
|
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
30
app/root.tsx
30
app/root.tsx
|
@ -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() {
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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"
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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",
|
|
||||||
email: "pengelola@example.com",
|
|
||||||
role: "Pengelola"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
|
const userSession = await requireUserSession(
|
||||||
|
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'. */
|
|
@ -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
|
|
@ -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>
|
||||||
|
|
|
@ -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();
|
|
@ -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();
|
|
@ -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();
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
Loading…
Reference in New Issue