diff --git a/app/components/layoutpengelola/header.tsx b/app/components/layoutpengelola/header.tsx index 447e8db..08eaede 100644 --- a/app/components/layoutpengelola/header.tsx +++ b/app/components/layoutpengelola/header.tsx @@ -1,4 +1,3 @@ -// app/components/layoutadmin/header.tsx import { useState } from "react"; import { Form } from "@remix-run/react"; import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; @@ -28,20 +27,33 @@ import { PanelLeftClose, PanelLeft } from "lucide-react"; +import { SessionData } from "~/sessions.server"; // Import SessionData type interface PengelolaHeaderProps { onMenuClick: () => void; sidebarCollapsed: boolean; isMobile: boolean; + user: SessionData; // Add user prop } export function PengelolaHeader({ onMenuClick, sidebarCollapsed, - isMobile + isMobile, + user // Add user prop }: PengelolaHeaderProps) { // 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 (
@@ -186,19 +198,19 @@ export function PengelolaHeader({
- MU + {getUserInitials("User")}
- Fahmi Kurniawan + {user.sessionId || "User"}
- Pengelola + {user.role || "Pengelola"}
@@ -210,9 +222,9 @@ export function PengelolaHeader({ className="w-56 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700" >
-
Fahmi Kurniawan
+
{user.phone || "User"}
- pengelola@example.com + {user.email || "user@example.com"}
diff --git a/app/components/layoutpengelola/layout-wrapper.tsx b/app/components/layoutpengelola/layout-wrapper.tsx index bef09b5..16aef72 100644 --- a/app/components/layoutpengelola/layout-wrapper.tsx +++ b/app/components/layoutpengelola/layout-wrapper.tsx @@ -1,12 +1,17 @@ import { useState, useEffect } from "react"; import { PengelolaSidebar } from "./sidebar"; import { PengelolaHeader } from "./header"; +import { SessionData } from "~/sessions.server"; // Import SessionData type interface PengelolaLayoutWrapperProps { 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 [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [isHovered, setIsHovered] = useState(false); @@ -47,6 +52,7 @@ export function PengelolaLayoutWrapper({ children }: PengelolaLayoutWrapperProps onClose={() => setSidebarOpen(false)} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} + // user={user} /> {/* Mobile overlay */} @@ -68,6 +74,7 @@ export function PengelolaLayoutWrapper({ children }: PengelolaLayoutWrapperProps onMenuClick={handleToggleSidebar} sidebarCollapsed={sidebarCollapsed} isMobile={isMobile} + user={user} /> {/* Page content */} diff --git a/app/lib/api-client.ts b/app/lib/api-client.ts new file mode 100644 index 0000000..b80df0a --- /dev/null +++ b/app/lib/api-client.ts @@ -0,0 +1,151 @@ +import axios, { + AxiosError, + AxiosInstance, + InternalAxiosRequestConfig +} from "axios"; + +export interface ApiResponse { + 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) => { + 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; diff --git a/app/lib/token-refresh.client.ts b/app/lib/token-refresh.client.ts new file mode 100644 index 0000000..1014222 --- /dev/null +++ b/app/lib/token-refresh.client.ts @@ -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"; + } + } + }); +} diff --git a/app/root.tsx b/app/root.tsx index 3b0beb3..c594f81 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -7,14 +7,18 @@ import { ScrollRestoration, useLoaderData } from "@remix-run/react"; +import { + json, + type LinksFunction, + type LoaderFunctionArgs +} from "@remix-run/node"; import clsx from "clsx"; import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from "remix-themes"; -import { themeSessionResolver } from "./sessions.server"; -import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node"; +import { getUserSession, themeSessionResolver } from "./sessions.server"; import { ProgressProvider } from "@bprogress/remix"; import "./tailwind.css"; @@ -38,9 +42,27 @@ export const links: LinksFunction = () => [ export async function loader({ request }: LoaderFunctionArgs) { 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() - }; + }); } export default function AppWithProviders() { diff --git a/app/routes/$.tsx b/app/routes/$.tsx new file mode 100644 index 0000000..294ac28 --- /dev/null +++ b/app/routes/$.tsx @@ -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 ( +
+
+

404

+

Halaman tidak ditemukan

+ + Kembali ke Home + +
+
+ ); +} \ No newline at end of file diff --git a/app/routes/authpengelola.requestotpforlogin._index.tsx b/app/routes/authpengelola.requestotpforlogin._index.tsx index 26a49e9..b1c592d 100644 --- a/app/routes/authpengelola.requestotpforlogin._index.tsx +++ b/app/routes/authpengelola.requestotpforlogin._index.tsx @@ -12,11 +12,14 @@ import { ArrowRight, AlertCircle, Loader2, - MessageSquare, CheckCircle, LogIn, Shield } 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) const LoginProgressIndicator = ({ currentStep = 1, totalSteps = 3 }) => { @@ -79,46 +82,70 @@ export const action = async ({ if (!phone) { errors.phone = "Nomor WhatsApp wajib diisi"; - } else { - // 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)"; - } + } else if (!validatePhoneNumber(phone)) { + errors.phone = "Format: 628xxxxxxxxx (9-14 digit setelah 62)"; } if (Object.keys(errors).length > 0) { return json({ errors }, { status: 400 }); } - // Simulasi cek apakah nomor terdaftar - const registeredPhones = ["6281234567890", "6281234567891", "6281234567892"]; - if (!registeredPhones.includes(phone)) { - return json( - { - errors: { - phone: "Nomor tidak terdaftar. Silakan daftar terlebih dahulu." - } - }, - { status: 404 } - ); - } + // Generate device ID untuk session ini + const deviceId = generateDeviceId("PengelolaLogin"); - // Simulasi kirim OTP - dalam implementasi nyata, integrate dengan WhatsApp Business API try { - console.log("Sending login OTP to WhatsApp:", phone); + // Request OTP untuk login + const response = await pengelolaAuthService.requestOtpLogin({ + phone, + role_name: "pengelola" + }); - // Simulasi delay API call - await new Promise((resolve) => setTimeout(resolve, 1000)); + // 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( + { + errors: { + phone: "Nomor tidak terdaftar. Silakan daftar terlebih dahulu." + } + }, + { status: 404 } + ); + } + + if (error.response?.status === 429) { + return json( + { + 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."; - // Redirect ke step berikutnya dengan nomor HP - return redirect( - `/authpengelola/verifyotptologin?phone=${encodeURIComponent(phone)}` - ); - } catch (error) { return json( { - errors: { general: "Gagal mengirim OTP. Silakan coba lagi." } + errors: { general: errorMessage } }, { status: 500 } ); @@ -255,18 +282,6 @@ export default function RequestOTPForLogin() {
- {/* Demo Info */} -
-

- Demo - Nomor Terdaftar: -

-
-

• 6281234567890

-

• 6281234567891

-

• 6281234567892

-
-
- {/* Submit Button */} - {/* Demo Credentials */} + {/* ✅ Updated demo credentials */}

Demo Credentials:

-

Email: admin@wastemanagement.com

-

Password: admin123

+

Email: pahmilucu123@gmail.com

+

Password: Halo12345,

+

+ ⚠️ OTP akan dikirim ke email setelah login +

diff --git a/app/services/auth/admin.service.ts b/app/services/auth/admin.service.ts new file mode 100644 index 0000000..1c53a49 --- /dev/null +++ b/app/services/auth/admin.service.ts @@ -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> { + const response = await apiClient.post>( + "/auth/login/admin", + data + ); + return response.data; + } + + async verifyOtp( + data: AdminOtpVerifyRequest + ): Promise> { + const response = await apiClient.post>( + "/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( + "/auth/register/admin", + data + ); + return response.data; + } + + async forgotPassword( + data: ForgotPasswordRequest + ): Promise> { + const response = await apiClient.post>( + "/auth/forgot-password", + data + ); + return response.data; + } + + async resetPassword(data: ResetPasswordRequest): Promise { + const response = await apiClient.post( + "/auth/reset-password", + data + ); + return response.data; + } + + async verifyEmail(data: VerifyEmailRequest): Promise { + const response = await apiClient.post( + "/auth/verify-email", + data + ); + return response.data; + } +} + +export default new AdminAuthService(); diff --git a/app/services/auth/common.service.ts b/app/services/auth/common.service.ts new file mode 100644 index 0000000..b256873 --- /dev/null +++ b/app/services/auth/common.service.ts @@ -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> { + const response = await apiClient.post>( + "/auth/refresh-token", + data + ); + return response.data; + } + + async logout(): Promise { + const response = await apiClient.post("/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(); diff --git a/app/services/auth/pengelola.service.ts b/app/services/auth/pengelola.service.ts new file mode 100644 index 0000000..073e42a --- /dev/null +++ b/app/services/auth/pengelola.service.ts @@ -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 { + const response = await apiClient.post( + "/auth/request-otp/register", + data + ); + return response.data; + } + + async requestOtpLogin(data: PengelolaOtpRequest): Promise { + const response = await apiClient.post( + "/auth/request-otp", + data + ); + return response.data; + } + + async verifyOtpRegister( + data: PengelolaOtpVerifyRequest + ): Promise> { + const response = await apiClient.post>( + "/auth/verif-otp/register", + data + ); + return response.data; + } + + async verifyOtpLogin( + data: PengelolaOtpVerifyRequest + ): Promise> { + const response = await apiClient.post>( + "/auth/verif-otp", + data + ); + return response.data; + } + + async createCompanyProfile( + data: CompanyProfileRequest + ): Promise> { + 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>( + "/companyprofile/create", + formData, + { + headers: { + "Content-Type": "multipart/form-data" + } + } + ); + return response.data; + } + + async checkApproval(): Promise> { + const response = await apiClient.get>( + "/auth/cekapproval" + ); + return response.data; + } + + async createPin(data: CreatePinRequest): Promise> { + const response = await apiClient.post>( + "/pin/create", + data + ); + return response.data; + } + + async verifyPin(data: VerifyPinRequest): Promise> { + const response = await apiClient.post>( + "/pin/verif", + data + ); + return response.data; + } +} + +export default new PengelolaAuthService(); diff --git a/app/sessions.server.tsx b/app/sessions.server.tsx index b16e3ac..47dd989 100644 --- a/app/sessions.server.tsx +++ b/app/sessions.server.tsx @@ -1,5 +1,26 @@ 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({ cookie: { @@ -8,9 +29,139 @@ const sessionStorage = createCookieSessionStorage({ path: '/', httpOnly: true, sameSite: 'lax', - secrets: ['s3cr3t'], + // secrets: ['s3cr3t'], + secrets: [process.env.SESSION_SECRET || "s3cr3t"], + secure: process.env.NODE_ENV === "production", // secure: true, }, }) -export const themeSessionResolver = createThemeSessionResolver(sessionStorage) \ No newline at end of file +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 { + 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) + diff --git a/app/types/auth.types.ts b/app/types/auth.types.ts new file mode 100644 index 0000000..47ed6de --- /dev/null +++ b/app/types/auth.types.ts @@ -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; +} diff --git a/app/utils/auth-utils.ts b/app/utils/auth-utils.ts new file mode 100644 index 0000000..c134ff8 --- /dev/null +++ b/app/utils/auth-utils.ts @@ -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; + } +} diff --git a/package-lock.json b/package-lock.json index 1f27c31..63203f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@remix-run/react": "^2.16.8", "@remix-run/serve": "^2.16.8", "@tabler/icons-react": "^3.34.0", + "axios": "^1.10.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "isbot": "^4.1.0", @@ -4699,6 +4700,12 @@ "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": { "version": "10.4.21", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", @@ -4762,6 +4769,17 @@ "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": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -5317,6 +5335,18 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "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": { "version": "2.0.3", "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" } }, + "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -6051,7 +6090,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7144,6 +7182,26 @@ "dev": true, "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": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -7187,6 +7245,22 @@ "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": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", @@ -11309,6 +11383,12 @@ "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/pump/-/pump-2.0.1.tgz", diff --git a/package.json b/package.json index f0a41ad..a535a26 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@remix-run/react": "^2.16.8", "@remix-run/serve": "^2.16.8", "@tabler/icons-react": "^3.34.0", + "axios": "^1.10.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "isbot": "^4.1.0",