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
+19 -29
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,42 +19,32 @@ 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;
document.cookie = `token=${token}; path=/; SameSite=Strict`;
const decoded = decodeJWT(token);
const role = decoded?.role;
if (role === "intern") {
navigate("/intern");
} else if (role === "mentor" || role === "admin") {
navigate("/mentor");
} 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);
const data = await login(email, password);
if (!data) return;
const { token } = data;
document.cookie = `token=${token}; path=/; SameSite=Strict`;
const decoded = decodeJWT(token);
const role = decoded?.role;
if (role === "intern") {
navigate("/intern");
} else if (role === "mentor" || role === "admin") {
navigate("/mentor");
} else {
setError("Role user tidak dikenali.");
}
};
@@ -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>
+20 -41
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;
setFormData({
fullName: data.full_name || "",
email: data.email || "",
bio: data.professional_bio || "",
});
} catch (err) {
console.error("Failed to fetch profile:", err)
} finally {
setLoading(false);
}
};
fetchData();
}, []);
if (user) {
setFormData({
fullName: user.full_name || "",
email: user.email || "",
bio: user.professional_bio || "",
});
}
}, [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", {
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);
}
await save({
full_name: formData.fullName,
email: formData.email,
professional_bio: formData.bio,
});
};
const initialLetter = formData.fullName ? formData.fullName.charAt(1).toUpperCase() : "U";
const initialLetter = formData.fullName ? formData.fullName.charAt(0).toUpperCase() : "U";
if (loading) {
return (
+26 -18
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">
<img
src={certificateImg}
alt={item.project.project_name}
className="w-full h-[160px] object-cover"
/>
<div className="w-full h-32 bg-gradient-to-br from-red-400 to-red-600 flex items-center justify-center">
<img
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>
+19 -41
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;
project && project.total_tasks > 0
? Math.round((project.task_done / project.total_tasks) * 100)
: 0;
const remainingTasks = current_task
? current_task.total_tasks - current_task.task_done
: 0;
const remainingTasks = project ? project.total_tasks - project.task_done : 0;
const handleClaim = async () => {
if (!finished_task) return;
const result = await claim(finished_task.id);
if (result) setJustClaimed(true);
};
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 />;
}