mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-15 11:21:57 +00:00
Added TFA timeout env variables
Added profile session management Added "Remember me" to bypass TFA using device fingerprint Fixed profile name not being persistent after logout and login
This commit is contained in:
@@ -25,6 +25,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FaYoutube } from "react-icons/fa";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { useUpdateNotification } from "../contexts/UpdateNotificationContext";
|
||||
@@ -875,13 +876,14 @@ const Layout = ({ children }) => {
|
||||
</div>
|
||||
|
||||
{/* Global Search Bar */}
|
||||
<div className="hidden md:flex items-center max-w-sm">
|
||||
<div className="flex items-center max-w-sm">
|
||||
<GlobalSearch />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center gap-x-4 lg:gap-x-6 justify-end">
|
||||
{/* External Links */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
{/* 1) GitHub */}
|
||||
<a
|
||||
href="https://github.com/PatchMon/PatchMon"
|
||||
target="_blank"
|
||||
@@ -896,6 +898,7 @@ const Layout = ({ children }) => {
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
{/* 2) Roadmap */}
|
||||
<a
|
||||
href="https://github.com/orgs/PatchMon/projects/2/views/1"
|
||||
target="_blank"
|
||||
@@ -905,15 +908,7 @@ const Layout = ({ children }) => {
|
||||
>
|
||||
<Route className="h-5 w-5" />
|
||||
</a>
|
||||
<a
|
||||
href="https://patchmon.net/discord"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
|
||||
title="Discord"
|
||||
>
|
||||
<DiscordIcon className="h-5 w-5" />
|
||||
</a>
|
||||
{/* 3) Docs */}
|
||||
<a
|
||||
href="https://docs.patchmon.net"
|
||||
target="_blank"
|
||||
@@ -923,6 +918,17 @@ const Layout = ({ children }) => {
|
||||
>
|
||||
<BookOpen className="h-5 w-5" />
|
||||
</a>
|
||||
{/* 4) Discord */}
|
||||
<a
|
||||
href="https://patchmon.net/discord"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
|
||||
title="Discord"
|
||||
>
|
||||
<DiscordIcon className="h-5 w-5" />
|
||||
</a>
|
||||
{/* 5) Email */}
|
||||
<a
|
||||
href="mailto:support@patchmon.net"
|
||||
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
|
||||
@@ -930,6 +936,17 @@ const Layout = ({ children }) => {
|
||||
>
|
||||
<Mail className="h-5 w-5" />
|
||||
</a>
|
||||
{/* 6) YouTube */}
|
||||
<a
|
||||
href="https://youtube.com/@patchmonTV"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
|
||||
title="YouTube Channel"
|
||||
>
|
||||
<FaYoutube className="h-5 w-5" />
|
||||
</a>
|
||||
{/* 7) Web */}
|
||||
<a
|
||||
href="https://patchmon.net"
|
||||
target="_blank"
|
||||
|
||||
@@ -54,7 +54,7 @@ const UsersTab = () => {
|
||||
});
|
||||
|
||||
// Update user mutation
|
||||
const updateUserMutation = useMutation({
|
||||
const _updateUserMutation = useMutation({
|
||||
mutationFn: ({ id, data }) => adminUsersAPI.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["users"]);
|
||||
|
||||
@@ -22,6 +22,7 @@ const Login = () => {
|
||||
const emailId = useId();
|
||||
const passwordId = useId();
|
||||
const tokenId = useId();
|
||||
const rememberMeId = useId();
|
||||
const { login, setAuthState } = useAuth();
|
||||
const [isSignupMode, setIsSignupMode] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -33,6 +34,7 @@ const Login = () => {
|
||||
});
|
||||
const [tfaData, setTfaData] = useState({
|
||||
token: "",
|
||||
remember_me: false,
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -127,7 +129,11 @@ const Login = () => {
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const response = await authAPI.verifyTfa(tfaUsername, tfaData.token);
|
||||
const response = await authAPI.verifyTfa(
|
||||
tfaUsername,
|
||||
tfaData.token,
|
||||
tfaData.remember_me,
|
||||
);
|
||||
|
||||
if (response.data?.token) {
|
||||
// Update AuthContext with the new authentication state
|
||||
@@ -158,9 +164,11 @@ const Login = () => {
|
||||
};
|
||||
|
||||
const handleTfaInputChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setTfaData({
|
||||
...tfaData,
|
||||
[e.target.name]: e.target.value.replace(/\D/g, "").slice(0, 6),
|
||||
[name]:
|
||||
type === "checkbox" ? checked : value.replace(/\D/g, "").slice(0, 6),
|
||||
});
|
||||
// Clear error when user starts typing
|
||||
if (error) {
|
||||
@@ -170,7 +178,7 @@ const Login = () => {
|
||||
|
||||
const handleBackToLogin = () => {
|
||||
setRequiresTfa(false);
|
||||
setTfaData({ token: "" });
|
||||
setTfaData({ token: "", remember_me: false });
|
||||
setError("");
|
||||
};
|
||||
|
||||
@@ -436,6 +444,23 @@ const Login = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id={rememberMeId}
|
||||
name="remember_me"
|
||||
type="checkbox"
|
||||
checked={tfaData.remember_me}
|
||||
onChange={handleTfaInputChange}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
|
||||
/>
|
||||
<label
|
||||
htmlFor={rememberMeId}
|
||||
className="ml-2 block text-sm text-secondary-700"
|
||||
>
|
||||
Remember me on this computer (skip TFA for 30 days)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
|
||||
<div className="flex">
|
||||
|
||||
@@ -2,12 +2,16 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Copy,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Key,
|
||||
LogOut,
|
||||
Mail,
|
||||
MapPin,
|
||||
Monitor,
|
||||
Moon,
|
||||
RefreshCw,
|
||||
Save,
|
||||
@@ -153,6 +157,7 @@ const Profile = () => {
|
||||
{ id: "profile", name: "Profile Information", icon: User },
|
||||
{ id: "password", name: "Change Password", icon: Key },
|
||||
{ id: "tfa", name: "Multi-Factor Authentication", icon: Smartphone },
|
||||
{ id: "sessions", name: "Active Sessions", icon: Monitor },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -533,6 +538,9 @@ const Profile = () => {
|
||||
|
||||
{/* Multi-Factor Authentication Tab */}
|
||||
{activeTab === "tfa" && <TfaTab />}
|
||||
|
||||
{/* Sessions Tab */}
|
||||
{activeTab === "sessions" && <SessionsTab />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1072,4 +1080,256 @@ const TfaTab = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Sessions Tab Component
|
||||
const SessionsTab = () => {
|
||||
const _queryClient = useQueryClient();
|
||||
const [_isLoading, _setIsLoading] = useState(false);
|
||||
const [message, setMessage] = useState({ type: "", text: "" });
|
||||
|
||||
// Fetch user sessions
|
||||
const {
|
||||
data: sessionsData,
|
||||
isLoading: sessionsLoading,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["user-sessions"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch("/api/v1/auth/sessions", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to fetch sessions");
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
// Revoke individual session mutation
|
||||
const revokeSessionMutation = useMutation({
|
||||
mutationFn: async (sessionId) => {
|
||||
const response = await fetch(`/api/v1/auth/sessions/${sessionId}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to revoke session");
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
setMessage({ type: "success", text: "Session revoked successfully" });
|
||||
refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
// Revoke all sessions mutation
|
||||
const revokeAllSessionsMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await fetch("/api/v1/auth/sessions", {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to revoke sessions");
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
setMessage({
|
||||
type: "success",
|
||||
text: "All other sessions revoked successfully",
|
||||
});
|
||||
refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
const formatRelativeTime = (dateString) => {
|
||||
const now = new Date();
|
||||
const date = new Date(dateString);
|
||||
const diff = now - date;
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (days > 0) return `${days} day${days > 1 ? "s" : ""} ago`;
|
||||
if (hours > 0) return `${hours} hour${hours > 1 ? "s" : ""} ago`;
|
||||
if (minutes > 0) return `${minutes} minute${minutes > 1 ? "s" : ""} ago`;
|
||||
return "Just now";
|
||||
};
|
||||
|
||||
const handleRevokeSession = (sessionId) => {
|
||||
if (window.confirm("Are you sure you want to revoke this session?")) {
|
||||
revokeSessionMutation.mutate(sessionId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeAllSessions = () => {
|
||||
if (
|
||||
window.confirm(
|
||||
"Are you sure you want to revoke all other sessions? This will log you out of all other devices.",
|
||||
)
|
||||
) {
|
||||
revokeAllSessionsMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-secondary-100">
|
||||
Active Sessions
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300">
|
||||
Manage your active sessions and devices. You can see where you're
|
||||
logged in and revoke access for any device.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
{message.text && (
|
||||
<div
|
||||
className={`rounded-md p-4 ${
|
||||
message.type === "success"
|
||||
? "bg-success-50 border border-success-200 text-success-700"
|
||||
: "bg-danger-50 border border-danger-200 text-danger-700"
|
||||
}`}
|
||||
>
|
||||
<div className="flex">
|
||||
{message.type === "success" ? (
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
) : (
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
)}
|
||||
<div className="ml-3">
|
||||
<p className="text-sm">{message.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sessions List */}
|
||||
{sessionsLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
) : sessionsData?.sessions?.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{/* Revoke All Button */}
|
||||
{sessionsData.sessions.filter((s) => !s.is_current_session).length >
|
||||
0 && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRevokeAllSessions}
|
||||
disabled={revokeAllSessionsMutation.isPending}
|
||||
className="inline-flex items-center px-4 py-2 border border-danger-300 text-sm font-medium rounded-md text-danger-700 bg-white hover:bg-danger-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-danger-500 disabled:opacity-50"
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
{revokeAllSessionsMutation.isPending
|
||||
? "Revoking..."
|
||||
: "Revoke All Other Sessions"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sessions */}
|
||||
{sessionsData.sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`border rounded-lg p-4 ${
|
||||
session.is_current_session
|
||||
? "border-primary-200 bg-primary-50 dark:border-primary-800 dark:bg-primary-900/20"
|
||||
: "border-secondary-200 bg-white dark:border-secondary-700 dark:bg-secondary-800"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Monitor className="h-5 w-5 text-secondary-500" />
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<h4 className="text-sm font-medium text-secondary-900 dark:text-secondary-100">
|
||||
{session.device_info?.browser} on{" "}
|
||||
{session.device_info?.os}
|
||||
</h4>
|
||||
{session.is_current_session && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200">
|
||||
Current Session
|
||||
</span>
|
||||
)}
|
||||
{session.tfa_remember_me && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-success-100 text-success-800 dark:bg-success-900 dark:text-success-200">
|
||||
Remembered
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
{session.device_info?.device} • {session.ip_address}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-4 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
<div className="flex items-center space-x-2">
|
||||
<MapPin className="h-4 w-4" />
|
||||
<span>
|
||||
{session.location_info?.city},{" "}
|
||||
{session.location_info?.country}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>
|
||||
Last active: {formatRelativeTime(session.last_activity)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>Created: {formatDate(session.created_at)}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>Login count: {session.login_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!session.is_current_session && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRevokeSession(session.id)}
|
||||
disabled={revokeSessionMutation.isPending}
|
||||
className="ml-4 inline-flex items-center px-3 py-2 border border-danger-300 text-sm font-medium rounded-md text-danger-700 bg-white hover:bg-danger-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-danger-500 disabled:opacity-50"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Monitor className="mx-auto h-12 w-12 text-secondary-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-secondary-100">
|
||||
No active sessions
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
You don't have any active sessions at the moment.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profile;
|
||||
|
||||
@@ -224,8 +224,8 @@ export const versionAPI = {
|
||||
export const authAPI = {
|
||||
login: (username, password) =>
|
||||
api.post("/auth/login", { username, password }),
|
||||
verifyTfa: (username, token) =>
|
||||
api.post("/auth/verify-tfa", { username, token }),
|
||||
verifyTfa: (username, token, remember_me = false) =>
|
||||
api.post("/auth/verify-tfa", { username, token, remember_me }),
|
||||
signup: (username, email, password, firstName, lastName) =>
|
||||
api.post("/auth/signup", {
|
||||
username,
|
||||
|
||||
Reference in New Issue
Block a user