feat: implement authentication system, role-based routing, and profile settings for interns and mentors

This commit is contained in:
Kemas M. Aryadary Rasyad
2026-06-28 10:13:50 +07:00
parent ad26a53f21
commit 37e21a1f39
17 changed files with 369 additions and 226 deletions
+6 -6
View File
@@ -42,11 +42,11 @@ import InternifyLogin from "./Pages/Internify/LoginInternify"
import FAQPage from "./Pages/Internify/FAQpage";
import NotificationList from "./Pages/Internify/NotificationList";
import SettingsContent from "./Pages/Internify/SettingsPage";
import MentorSettingsPage from "./Pages/Internify/mentor/MentorSettingsPage";
import TaskSubmission from "./Pages/Internify/intern/TaskSubmission";
import SertificateList from "./Pages/Internify/intern/SertificateList";
import MentorProjectsDetailPage from "./Pages/Internify/mentor/MentorProjectDetailPage";
import ViewInternSubmission from "./Pages/Internify/mentor/ViewInternSubmission";
import SettingsPage from "./Pages/Internify/mentor/MentorSettingsPage";
import ProtectedRoutes from "./Pages/utils/ProtectedRoute";
import CertificateDetail from "./Pages/Internify/mentor/CertificateDetail";
import CertificateResult from "./Pages/Internify/mentor/CertificateGenerate";
@@ -96,8 +96,8 @@ function App() {
<Route path="/intern" element={<InternLayout />}>
<Route index element={<HomeInternPage />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="certificates" element={<SertificatePage />} />
<Route path="certificates/all" element={<SertificateList />} />
<Route path="certificates" element={<SertificateList />} />
<Route path="certificates/:slug" element={<SertificatePage />} />
<Route path="projects/:slug" element={<ProjectDetailPage />} />
<Route path="projects/:slug/:taskSlug" element={<TaskSubmission />} />
<Route path="faq" element={<FAQPage />} />
@@ -106,8 +106,8 @@ function App() {
</Route>
</Route>
{/* Mentor only */}
<Route element={<ProtectedRoutes allowedRoles={["admin"]} />}>
{/* Mentor/Admin only */}
<Route element={<ProtectedRoutes allowedRoles={["admin", "mentor"]} />}>
<Route path="/mentor" element={<MentorLayout />}>
<Route index element={<HomeMentorPage />} />
<Route path="projects" element={<MentorProjectsPage />} />
@@ -120,7 +120,7 @@ function App() {
<Route path="projects/:slug/:taskSlug/:nameIntern" element={<ViewInternSubmission />} />
<Route path="faq" element={<FAQPage />} />
<Route path="notifications" element={<NotificationList />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="settings" element={<MentorSettingsPage />} />
</Route>
</Route>
+41 -2
View File
@@ -1,9 +1,30 @@
import { Bell, Menu } from "lucide-react"
import humiclogo from "../assets/logo.png";
import { useNavigate } from "react-router-dom";
import { useCurrentUser } from "../hooks/useUser";
/** Singkatan nama: ambil huruf pertama setiap kata, maksimal 2 huruf.
* "Andi Mahesa" → "AM"
* "Andi Bayu Cendika" → "AC" (huruf pertama + huruf pertama kata terakhir)
*/
function getInitials(fullName: string): string {
const parts = fullName.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "U";
if (parts.length === 1) return parts[0].charAt(0).toUpperCase();
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
}
export default function Header({ toggleSidebar }: { toggleSidebar: () => void }) {
const nav = useNavigate();
const { user } = useCurrentUser();
// Tentukan base path settings sesuai role
const settingsPath =
user?.role === "intern" ? "/intern/settings" : "/mentor/settings";
const initials = user ? getInitials(user.full_name) : "U";
const hasPhoto = !!user?.profile_picture;
return (
<header className='sticky top-0 flex w-full justify-between items-center py-4 px-12 bg-white border-b border-gray-300 z-10'>
<div className='flex items-center gap-3'>
@@ -20,12 +41,30 @@ export default function Header({ toggleSidebar }: { toggleSidebar: () => void })
</div>
<div className="flex flex-row gap-6 items-center">
<button onClick={()=>nav("notifications")}>
<button onClick={() => nav("notifications")}>
<Bell className="w-5 h-5 hover:text-red-800"/>
</button>
<div className="bg-red rounded-full w-9 h-9">
{/* Gambar Profil */}
<button
onClick={() => nav(settingsPath)}
className="rounded-full w-9 h-9 overflow-hidden border-2 border-gray-200 hover:border-red-400 transition-colors flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-red-400"
title="Profile Settings"
>
{hasPhoto ? (
<img
src={user!.profile_picture!}
alt={user!.full_name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-[#B30000] flex items-center justify-center">
<span className="text-white text-xs font-bold tracking-wide">
{initials}
</span>
</div>
)}
</button>
</div>
</header>
)
+9 -19
View File
@@ -2,7 +2,7 @@ import { useState } from "react";
import logo from "../../assets/logo.png";
import { useNavigate } from "react-router-dom";
import { Mail, Lock, Eye, EyeOff } from 'lucide-react';
import api from "../../lib/api";
import { useLogin } from "../../hooks/useUser";
const decodeJWT = (token: string) => {
try {
@@ -19,23 +19,22 @@ export default function InternifyLogin() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const { login, loading, error, setError } = useLogin();
const handleSignIn = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setError(null);
if (!email || !password) {
setError("Email dan password wajib diisi.");
return;
}
try {
setLoading(true);
const response = await api.post("/auth-api/login", { email, password });
console.log(response.data.data)
const { token } = response.data.data;
const data = await login(email, password);
if (!data) return;
const { token } = data;
document.cookie = `token=${token}; path=/; SameSite=Strict`;
const decoded = decodeJWT(token);
@@ -47,15 +46,6 @@ export default function InternifyLogin() {
} else {
setError("Role user tidak dikenali.");
}
} catch (err: any) {
if (err.response?.status === 401 || err.response?.status === 400) {
setError("Email atau password salah.");
} else {
setError("Terjadi kesalahan. Coba beberapa saat lagi.");
}
} finally {
setLoading(false);
}
};
return (
@@ -130,7 +120,7 @@ export default function InternifyLogin() {
disabled={loading}
className="w-full bg-[#B30000] hover:bg-[#990000] text-white font-medium text-sm py-2.5 rounded-lg transition-colors shadow-sm mt-2"
>
Sign In
{loading ? "Signing in..." : "Sign In"}
</button>
</form>
+13 -34
View File
@@ -1,36 +1,26 @@
import { useState, useEffect } from "react";
import api from "../../lib/api";
import { useCurrentUser, useUpdateProfile } from "../../hooks/useUser";
export default function SettingsContent() {
const { user, loading } = useCurrentUser();
const { save, loading: saving, error: errorMsg, successMsg } = useUpdateProfile();
const [formData, setFormData] = useState({
fullName: "",
email: "",
bio: ""
bio: "",
});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [successMsg, setSuccessMsg] = useState("");
const [errorMsg, setErrorMsg] = useState("");
// Sync form data when user is loaded
useEffect(() => {
const fetchData = async () => {
try {
const response = await api.patch("/auth-api/update-profile");
const data = response.data.data;
if (user) {
setFormData({
fullName: data.full_name || "",
email: data.email || "",
bio: data.professional_bio || "",
fullName: user.full_name || "",
email: user.email || "",
bio: user.professional_bio || "",
});
} catch (err) {
console.error("Failed to fetch profile:", err)
} finally {
setLoading(false);
}
};
fetchData();
}, []);
}, [user]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
@@ -39,25 +29,14 @@ export default function SettingsContent() {
const handleSaveChanges = async (e: React.FormEvent) => {
e.preventDefault();
setSuccessMsg("");
setErrorMsg("");
setSaving(true);
try {
await api.patch("/auth-api/update-profile", {
await save({
full_name: formData.fullName,
email: formData.email,
professional_bio: formData.bio,
});
setSuccessMsg("Profile updated successfully!");
} catch (err: any) {
setErrorMsg(err?.response?.data?.message || "Failed to save changes.");
} finally {
setSaving(false);
}
};
const initialLetter = formData.fullName ? formData.fullName.charAt(1).toUpperCase() : "U";
const initialLetter = formData.fullName ? formData.fullName.charAt(0).toUpperCase() : "U";
if (loading) {
return (
+24 -16
View File
@@ -1,11 +1,11 @@
import { Award, Loader2, AlertCircle } from "lucide-react";
import { useMyCertificates } from "../../../hooks/useSertificates";
import type { Certificate } from "../../../types/certificate.types";
import certificateImg from "../../../assets/certificate.png";
import { Loader2, AlertCircle } from "lucide-react";
import { useMyProjects } from "../../../hooks/useProjects";
import type { Project } from "../../../types/project.types";
import { useNavigate } from "react-router-dom";
export default function SertificateList() {
const { certificates, loading, error } = useMyCertificates();
const { projects, loading, error } = useMyProjects();
const navigate = useNavigate();
return (
<div>
{/* Header */}
@@ -31,44 +31,52 @@ export default function SertificateList() {
)}
{/* Empty */}
{!loading && !error && certificates.length === 0 && (
{!loading && !error && projects.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
<p className="text-sm">No certificates yet.</p>
<p className="text-sm">No ongoing projects yet.</p>
</div>
)}
{/* Grid */}
{!loading && !error && certificates.length > 0 && (
{!loading && !error && projects.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
{certificates.map((item: Certificate) => (
{projects.map((item: Project) => (
<div
key={item.id}
className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-shadow cursor-pointer group"
onClick={() => navigate(`/intern/certificates/${item.slug}`)}
>
{/* Thumbnail */}
<div className="p-6 pb-0">
<div className="rounded-lg overflow-hidden">
<div className="w-full h-32 bg-gradient-to-br from-red-400 to-red-600 flex items-center justify-center">
<img
src={certificateImg}
alt={item.project.project_name}
className="w-full h-[160px] object-cover"
src={item.project_icon}
alt={item.project_name}
className="w-16 h-16 object-contain"
/>
</div>
</div>
</div>
{/* Info */}
<div className="px-4 py-3">
<p className="text-xs text-gray-400 mb-0.5">
{new Date(item.issued_at).toLocaleDateString("id-ID", {
{new Date(item.start_date).toLocaleDateString("id-ID", {
year: "numeric",
month: "long",
})}
until
{new Date(item.start_date).toLocaleDateString("id-ID", {
year: "numeric",
month: "long",
})}
</p>
<p className="text-sm font-semibold text-gray-800 leading-snug">
{item.project.project_name}
{item.end_date}
</p>
<p className="text-xs text-gray-500 mt-1">
No: <span className="font-bold text-red-600">{item.certificate_no}</span>
No: <span className="font-bold text-red-600">{item.status}</span>
</p>
</div>
</div>
+18 -40
View File
@@ -1,52 +1,32 @@
import { useState, useMemo } from "react";
import { useMemo } from "react";
import { useParams } from "react-router-dom";
import CertificateAvailable from "./components/CertificateAvailabe";
import CertificateNotAvailable from "./components/CertificateNotAvailable";
import SertificateHistory from "./components/CertificateHistory";
import { useMyCertificates, useClaimCertificate } from "../../../hooks/useSertificates";
import { useMyProjects } from "../../../hooks/useProjects";
import type { ProjectDetail } from "../../../types/project.types";
import { useMyCertificates } from "../../../hooks/useSertificates";
import { useProjectDetail } from "../../../hooks/useProjects";
const SertificatePage = () => {
const { projects, loading: projectsLoading } = useMyProjects();
const { slug } = useParams<{ slug: string }>();
const { project, loading: projectLoading } = useProjectDetail(slug ?? "");
const { certificates, loading: certLoading } = useMyCertificates();
const { claim, loading: claiming } = useClaimCertificate();
const [justClaimed, setJustClaimed] = useState(false);
const current_task = useMemo(() => {
if (!projects || projects.length === 0) return null;
return projects.find((p) => p.task_done < p.total_tasks) ?? null;
}, [projects]);
const allTasksDone = !projectLoading && !!project && project.task_done >= project.total_tasks;
const finished_task = useMemo((): ProjectDetail | null => {
if (!projects || projects.length === 0) return null;
return projects.find((p) => p.task_done >= p.total_tasks) ?? null;
}, [projects]);
// Cek apakah finished_task sudah punya certificate di backend
const alreadyClaimed = useMemo(() => {
if (!finished_task || !certificates.length) return false;
return certificates.some((cert) => cert.project.id === finished_task.id);
}, [certificates, finished_task]);
const allTasksDone = !projectsLoading && current_task === null && projects.length > 0;
const alreadyDone = useMemo(() => {
if (!project || !certificates.length) return false;
return certificates.some((cert) => cert.project.id === project.id);
}, [certificates, project]);
const progress =
current_task && current_task.total_tasks > 0
? Math.round((current_task.task_done / current_task.total_tasks) * 100)
: 100;
const remainingTasks = current_task
? current_task.total_tasks - current_task.task_done
project && project.total_tasks > 0
? Math.round((project.task_done / project.total_tasks) * 100)
: 0;
const handleClaim = async () => {
if (!finished_task) return;
const result = await claim(finished_task.id);
if (result) setJustClaimed(true);
};
const remainingTasks = project ? project.total_tasks - project.task_done : 0;
const isLoading = projectsLoading || certLoading;
const showCertificate = alreadyClaimed || justClaimed;
const isLoading = projectLoading || certLoading;
const showCertificate = alreadyDone;
if (isLoading) {
return (
@@ -55,7 +35,7 @@ const SertificatePage = () => {
</div>
);
}
console.log(finished_task)
return (
<div>
<div className="mb-4 flex flex-col gap-1">
@@ -65,14 +45,12 @@ const SertificatePage = () => {
<div className="flex flex-col lg:flex-row gap-6 items-stretch">
<div className="w-full lg:w-5/8 flex flex-col">
{showCertificate ? (
<CertificateAvailable project={finished_task} />
<CertificateAvailable project={project} />
) : (
<CertificateNotAvailable
progress={progress}
remainingTasks={remainingTasks}
allTasksDone={allTasksDone}
onClaim={handleClaim}
claiming={claiming}
/>
)}
</div>
@@ -1,19 +1,15 @@
import { Lock, Award, Loader2 } from "lucide-react";
import { Clock, Lock, Award } from "lucide-react";
interface CertificateNotAvailableProps {
progress: number;
remainingTasks: number;
allTasksDone?: boolean;
onClaim?: () => void;
claiming?: boolean;
}
export default function CertificateNotAvailable({
progress,
remainingTasks,
allTasksDone = false,
onClaim,
claiming = false,
}: CertificateNotAvailableProps) {
return (
<div className="bg-box-primary w-full h-full rounded-2xl border border-box-border shadow-sm px-24 py-12 flex flex-col items-center justify-center">
@@ -27,9 +23,9 @@ export default function CertificateNotAvailable({
<Lock className="w-12 h-12 text-red-700" />
)}
</div>
{/* <div className="absolute -bottom-2 -right-2 w-7 h-7 bg-amber-500 rounded- flex items-center justify-center shadow-md">
<div className="absolute -bottom-2 -right-2 w-7 h-7 bg-amber-500 rounded- flex items-center justify-center shadow-md">
<Clock className="w-4 h-4 text-yellow-800" />
</div> */}
</div>
</div>
</div>
@@ -50,27 +46,8 @@ export default function CertificateNotAvailable({
<div className="w-full h-3 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-green-500 rounded-full transition-all duration-700 w-full" />
</div>
<p className="text-xs text-gray-400 mt-2 text-left">Semua tugas telah diselesaikan!</p>
<p className="text-xs text-gray-400 mt-2 text-left">Semua tugas telah diselesaikan! Silahkan tunggu untuk mendapatkan sertifikat</p>
</div>
{/* Tombol Claim */}
<button
onClick={onClaim}
disabled={claiming}
className="flex items-center justify-center gap-2 px-8 py-3 bg-red-700 hover:bg-red-800 disabled:opacity-60 active:scale-95 transition-all text-white font-semibold rounded-xl text-sm"
>
{claiming ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Mengklaim...
</>
) : (
<>
<Award className="w-4 h-4" />
Claim Certificate
</>
)}
</button>
</>
) : (
<>
@@ -23,7 +23,7 @@ const ProjectCard = (project: Project) => {
className=" rounded-xl overflow-hidden shadow-sm border border-gray-100 w-full flex flex-col cursor-pointer hover:shadow-md transition-shadow duration-200"
>
{/* Thumbnail */}
<div className="h-[150px] bg-gradient-to-tr from-red-700 to-red-800 overflow-hidden flex items-center justify-center">
<div style={{ backgroundColor: project.background_color ?? '#dc2626' }} className="h-[150px] overflow-hidden flex items-center justify-center">
<Icon className="w-12 h-12 text-white opacity-80" />
</div>
@@ -47,17 +47,13 @@ const MentorCertificatePage = () => {
return (
<div>
{/* Title */}
<div className="mb-8">
<h1 className="text-3xl md:text-4xl font-bold text-gray-900 tracking-tight">Certificates</h1>
<p className="text-sm md:text-base text-gray-500 mt-1">
Administrate, issue, and track professional certifications for all interns.
</p>
</div>
{/* Main Grid Content */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 items-start">
{/* Kiri: Form Issue New Certificate (4 Kolom) */}
<div className="bg-white border border-gray-100 rounded-2xl p-6 md:p-8 shadow-sm">
<div className="flex items-center gap-3 mb-6">
<PlusCircle className="w-6 h-6 text-[#B30000]" />
@@ -65,7 +61,6 @@ const MentorCertificatePage = () => {
</div>
<form onSubmit={handleIssueCertificate} className="space-y-5">
{/* Select Intern */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-700 tracking-wide">Select Intern</label>
<select
@@ -79,7 +74,6 @@ const MentorCertificatePage = () => {
</select>
</div>
{/* Select Project / Course */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-700 tracking-wide">Select Project / Course</label>
<select
@@ -92,8 +86,6 @@ const MentorCertificatePage = () => {
<option value="UI/UX Essentials">UI/UX Essentials</option>
</select>
</div>
{/* Certificate Image Upload Area */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-700 tracking-wide">Certificate Image</label>
<div className="border-2 border-dashed border-red-200/60 bg-red-50/10 rounded-xl p-6 text-center flex flex-col items-center justify-center cursor-pointer hover:bg-red-50/20 transition-colors group">
@@ -1,30 +1,44 @@
import { useState, useRef } from "react";
const mockProfileData = {
fullName: "JonathanKristina",
email: "JonathanKristina@gmail.com",
bio: "UIUX holic"
};
import { useState, useRef, useEffect } from "react";
import { useCurrentUser, useUpdateProfile } from "../../../hooks/useUser";
export default function MentorSettingsPage() {
const [formData, setFormData] = useState(mockProfileData);
const { user, loading: userLoading } = useCurrentUser();
const { save, loading: saving, error: errorMsg, successMsg } = useUpdateProfile();
const [formData, setFormData] = useState({
fullName: "",
email: "",
bio: "",
});
// File state untuk profile_picture
const [photoFile, setPhotoFile] = useState<File | null>(null);
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Sync form data ketika user sudah di-fetch
useEffect(() => {
if (user) {
setFormData({
fullName: user.full_name || "",
email: user.email || "",
bio: user.professional_bio || "",
});
if (user.profile_picture) {
setPhotoPreview(user.profile_picture);
}
}
}, [user]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSaveChanges = (e: React.FormEvent) => {
e.preventDefault();
alert("Changes saved successfully!");
console.log("Saved Data:", formData);
};
const handlePhotoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setPhotoFile(file);
const reader = new FileReader();
reader.onloadend = () => setPhotoPreview(reader.result as string);
reader.readAsDataURL(file);
@@ -32,12 +46,35 @@ export default function MentorSettingsPage() {
};
const handleRemovePhoto = () => {
setPhotoFile(null);
setPhotoPreview(null);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleSaveChanges = async (e: React.FormEvent) => {
e.preventDefault();
await save({
full_name: formData.fullName,
email: formData.email,
professional_bio: formData.bio,
...(photoFile ? { profile_picture: photoFile } : {}),
});
// Reset file state setelah berhasil save
if (!errorMsg) {
setPhotoFile(null);
}
};
const initialLetter = formData.fullName ? formData.fullName.charAt(0).toUpperCase() : "U";
if (userLoading) {
return (
<div className="flex items-center justify-center h-40 text-sm text-gray-400">
Loading profile...
</div>
);
}
return (
<div>
{/* Header Title */}
@@ -47,6 +84,7 @@ export default function MentorSettingsPage() {
Manage and edit your profile and account information.
</p>
</div>
{/* White Card Container */}
<div className="w-full bg-white rounded-xl border border-box-border shadow-lg overflow-hidden">
@@ -91,6 +129,11 @@ export default function MentorSettingsPage() {
<p className="text-xs text-gray-400 mb-3">
JPG, GIF or PNG. Recommended size 800x800px. Max size of 800K.
</p>
{photoFile && (
<p className="text-xs text-blue-500 mb-2 font-medium">
📎 {photoFile.name}
</p>
)}
<div className="flex items-center gap-2">
<button
type="button"
@@ -166,13 +209,26 @@ export default function MentorSettingsPage() {
</p>
</div>
{/* Feedback Messages */}
{successMsg && (
<p className="text-xs text-green-600 bg-green-50 p-2 rounded-md font-medium">
{successMsg}
</p>
)}
{errorMsg && (
<p className="text-xs text-red-600 bg-red-50 p-2 rounded-md font-medium">
{errorMsg}
</p>
)}
{/* Footer Action */}
<div className="flex justify-end pt-2">
<button
type="submit"
className="px-6 py-2 bg-[#B30000] hover:bg-[#990000] text-white font-bold text-xs rounded-lg shadow-sm transition-colors uppercase tracking-wider"
disabled={saving}
className="px-6 py-2 bg-[#B30000] hover:bg-[#990000] text-white font-bold text-xs rounded-lg shadow-sm transition-colors uppercase tracking-wider disabled:opacity-60"
>
Save Changes
{saving ? "Saving..." : "Save Changes"}
</button>
</div>
@@ -4,11 +4,7 @@ import * as LucideIcons from 'lucide-react';
import type { LucideProps } from 'lucide-react';
import type { Project } from '../../../../types/project.types';
const getDynamicIcon = (iconName: string) => {
const formatted = iconName.charAt(0).toUpperCase() + iconName.slice(1);
const Icon = (LucideIcons as unknown as Record<string, React.ComponentType<LucideProps>>)[formatted];
return Icon ?? LucideIcons.FolderOpen;
};
const toSlug = (name: string) => name.toLowerCase().replace(/\s+/g, '-');
+3 -3
View File
@@ -1,7 +1,7 @@
import { Outlet, Navigate } from "react-router-dom";
interface PrivateRouteProps {
allowedRoles?: ("intern" | "admin")[];
allowedRoles?: ("intern" | "admin" | "mentor")[];
}
const decodeJWT = (token: string) => {
@@ -32,7 +32,7 @@ const ProtectedRoutes = ({ allowedRoles }: PrivateRouteProps) => {
}
const decoded = decodeJWT(token);
const userRole = decoded?.role as"intern" | "admin" | undefined;
const userRole = decoded?.role as "intern" | "admin" | "mentor" | undefined;
if (!userRole) {
return <Navigate to="/login-internify" replace />;
@@ -40,7 +40,7 @@ const ProtectedRoutes = ({ allowedRoles }: PrivateRouteProps) => {
if (allowedRoles && !allowedRoles.includes(userRole)) {
// Jika role tidak diizinkan, kembalikan ke base dashboard masing-masing role
if (userRole === "admin") return <Navigate to="/mentor" replace />;
if (userRole === "admin" || userRole === "mentor") return <Navigate to="/mentor" replace />;
if (userRole === "intern") return <Navigate to="/intern" replace />;
}
+6 -3
View File
@@ -1,5 +1,5 @@
// hooks/useMahasiswa.ts
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import type { Mahasiswa } from '../types/project.types';
import { getMahasiswa } from '../services/MahasiswaService';
@@ -8,12 +8,15 @@ export const useMahasiswa = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const refetch = useCallback(() => {
setLoading(true);
getMahasiswa()
.then(setMahasiswa)
.catch(() => setError('Gagal memuat data mahasiswa.'))
.finally(() => setLoading(false));
}, []);
return { mahasiswa, loading, error };
useEffect(() => { refetch(); }, [refetch]);
return { mahasiswa, loading, error, refetch };
};
+16 -8
View File
@@ -67,7 +67,7 @@ export const useProjectDetail = (id: string) => {
//Khusus Ambil Intern dari Project/{id}
export const useProjectInterns = (projectId: string) => {
const [interns, setInterns] = useState<ProjectMember[]>([] );
const [interns, setInterns] = useState<ProjectMember[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -89,30 +89,35 @@ export const useMyProjects = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const refetch = useCallback(() => {
setLoading(true);
getMyProjects()
.then(setProjects)
.catch(() => setError("Gagal memuat my projects."))
.finally(() => setLoading(false));
}, []);
return { projects, loading, error };
};
useEffect(() => { refetch(); }, [refetch]);
return { projects, loading, error, refetch };
};
// GET /project-api/my-tasks
export const useMyTasks = () => {
const [tasks, setTasks] = useState<InternTaskItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const refetch = useCallback(() => {
setLoading(true);
getMyTasks()
.then(setTasks)
.catch(() => setError("Gagal memuat tasks."))
.finally(() => setLoading(false));
}, []);
return { tasks, loading, error };
useEffect(() => { refetch(); }, [refetch]);
return { tasks, loading, error, refetch };
};
// GET /project-api/mentor-projects
@@ -121,14 +126,17 @@ export const useMentorProjects = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const refetch = useCallback(() => {
setLoading(true);
getMentorProjects()
.then(setProjects)
.catch(() => setError("Gagal memuat mentor projects."))
.finally(() => setLoading(false));
}, []);
return { projects, loading, error };
useEffect(() => { refetch(); }, [refetch]);
return { projects, loading, error, refetch };
};
// GET /project-api/interns
+15 -7
View File
@@ -18,30 +18,35 @@ export const useMyCertificates = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const refetch = useCallback(() => {
setLoading(true);
getMyCertificates()
.then(setCertificates)
.catch(() => setError("Gagal memuat sertifikat."))
.finally(() => setLoading(false));
}, []);
return { certificates, loading, error };
};
useEffect(() => { refetch(); }, [refetch]);
return { certificates, loading, error, refetch };
};
// GET /certificate-api/all
export const useAllCertificates = () => {
const [certificates, setCertificates] = useState<Certificate[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const refetch = useCallback(() => {
setLoading(true);
getAllCertificates()
.then(setCertificates)
.catch(() => setError("Gagal memuat semua sertifikat."))
.finally(() => setLoading(false));
}, []);
return { certificates, loading, error };
useEffect(() => { refetch(); }, [refetch]);
return { certificates, loading, error, refetch };
};
// GET /certificate-api/detail/{id}
@@ -67,15 +72,18 @@ export const useProjectCertificates = (id_project: number) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const refetch = useCallback(() => {
if (!id_project) return;
setLoading(true);
getCertificatesByProject(id_project)
.then(setCertificates)
.catch(() => setError("Gagal memuat sertifikat project."))
.finally(() => setLoading(false));
}, [id_project]);
return { certificates, loading, error };
useEffect(() => { refetch(); }, [refetch]);
return { certificates, loading, error, refetch };
};
// GET /certificate-api/verify & verify-uuid (public, on-demand)
+81 -7
View File
@@ -1,24 +1,98 @@
import { useState, useEffect } from "react";
import api from "../lib/api";
// hooks/useUser.ts
import { useState, useEffect, useCallback } from "react";
import { loginUser, getProfile, updateProfile } from "../services/UserService";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface CurrentUser {
id: number;
full_name: string;
email: string;
role: "mentor" | "intern" | "admin";
professional_bio?: string;
profile_picture?: string | null;
}
export interface UpdateProfilePayload {
full_name?: string;
email?: string;
professional_bio?: string;
profile_picture?: File | null;
}
// ─── useCurrentUser ───────────────────────────────────────────────────────────
// GET /auth-api/me — ambil data user yang sedang login
export const useCurrentUser = () => {
const [user, setUser] = useState<CurrentUser | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.get("/auth-api/me")
.then((res) => setUser(res.data.data))
.catch(() => setError("Gagal memuat user."))
const refetch = useCallback(() => {
setLoading(true);
setError(null);
getProfile()
.then(setUser)
.catch(() => setError("Gagal memuat data user."))
.finally(() => setLoading(false));
}, []);
return { user, loading, error };
useEffect(() => {
refetch();
}, [refetch]);
return { user, loading, error, refetch };
};
// ─── useLogin ────────────────────────────────────────────────────────────────
// POST /auth-api/login
export const useLogin = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const login = async (email: string, password: string) => {
setLoading(true);
setError(null);
try {
const data = await loginUser({ email, password });
return data; // { token }
} catch (err: any) {
const status = err?.response?.status;
if (status === 401 || status === 400) {
setError("Email atau password salah.");
} else {
setError("Terjadi kesalahan. Coba beberapa saat lagi.");
}
return null;
} finally {
setLoading(false);
}
};
return { login, loading, error, setError };
};
// ─── useUpdateProfile ────────────────────────────────────────────────────────
// PATCH /auth-api/update-profile
export const useUpdateProfile = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
const save = async (payload: UpdateProfilePayload) => {
setLoading(true);
setError(null);
setSuccessMsg(null);
try {
const updated = await updateProfile(payload);
setSuccessMsg("Profile updated successfully!");
return updated;
} catch (err: any) {
setError(err?.response?.data?.message || "Failed to save changes.");
return null;
} finally {
setLoading(false);
}
};
return { save, loading, error, successMsg, setSuccessMsg, setError };
};
+36 -1
View File
@@ -1,3 +1,38 @@
// services/UserService.ts
import api from "../lib/api";
import type { CurrentUser, UpdateProfilePayload } from "../hooks/useUser";
// POST auth-api/login
// POST /auth-api/login
export const loginUser = async (payload: {
email: string;
password: string;
}): Promise<{ token: string }> => {
const res = await api.post("/auth-api/login", payload);
return res.data.data;
};
// GET /auth-api/me
export const getProfile = async (): Promise<CurrentUser> => {
const res = await api.get("/auth-api/me");
return res.data.data;
};
// PATCH /auth-api/update-profile
// Jika payload mengandung profile_picture (File), kirim sebagai multipart/form-data
export const updateProfile = async (
payload: UpdateProfilePayload
): Promise<CurrentUser> => {
if (payload.profile_picture instanceof File) {
const formData = new FormData();
if (payload.full_name !== undefined) formData.append("full_name", payload.full_name);
if (payload.email !== undefined) formData.append("email", payload.email);
if (payload.professional_bio !== undefined) formData.append("professional_bio", payload.professional_bio);
formData.append("profile_picture", payload.profile_picture);
const res = await api.patch("/auth-api/update-profile", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return res.data.data;
}
const res = await api.patch("/auth-api/update-profile", payload);
return res.data.data;
};