mirror of https://github.com/usememos/memos
feat: implement user sessions
parent
6e4d1d9100
commit
4e3a4e36f6
@ -0,0 +1,179 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/grpc/metadata"
|
||||
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
)
|
||||
|
||||
func TestParseUserAgent(t *testing.T) {
|
||||
service := &APIV1Service{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
userAgent string
|
||||
expectedDevice string
|
||||
expectedOS string
|
||||
expectedBrowser string
|
||||
}{
|
||||
{
|
||||
name: "Chrome on Windows",
|
||||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
|
||||
expectedDevice: "desktop",
|
||||
expectedOS: "Windows 10/11",
|
||||
expectedBrowser: "Chrome 119.0.0.0",
|
||||
},
|
||||
{
|
||||
name: "Safari on macOS",
|
||||
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15",
|
||||
expectedDevice: "desktop",
|
||||
expectedOS: "macOS 10.15.7",
|
||||
expectedBrowser: "Safari 17.0",
|
||||
},
|
||||
{
|
||||
name: "Chrome on Android Mobile",
|
||||
userAgent: "Mozilla/5.0 (Linux; Android 13; SM-G998B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36",
|
||||
expectedDevice: "mobile",
|
||||
expectedOS: "Android 13",
|
||||
expectedBrowser: "Chrome 119.0.0.0",
|
||||
},
|
||||
{
|
||||
name: "Safari on iPhone",
|
||||
userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
|
||||
expectedDevice: "mobile",
|
||||
expectedOS: "iOS 17.0",
|
||||
expectedBrowser: "Safari 17.0",
|
||||
},
|
||||
{
|
||||
name: "Firefox on Windows",
|
||||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0",
|
||||
expectedDevice: "desktop",
|
||||
expectedOS: "Windows 10/11",
|
||||
expectedBrowser: "Firefox 119.0",
|
||||
},
|
||||
{
|
||||
name: "Edge on Windows",
|
||||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0",
|
||||
expectedDevice: "desktop",
|
||||
expectedOS: "Windows 10/11",
|
||||
expectedBrowser: "Edge 119.0.0.0",
|
||||
},
|
||||
{
|
||||
name: "iPad Safari",
|
||||
userAgent: "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
|
||||
expectedDevice: "tablet",
|
||||
expectedOS: "iOS 17.0",
|
||||
expectedBrowser: "Safari 17.0",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
clientInfo := &storepb.SessionsUserSetting_ClientInfo{}
|
||||
service.parseUserAgent(tt.userAgent, clientInfo)
|
||||
|
||||
if clientInfo.DeviceType != tt.expectedDevice {
|
||||
t.Errorf("Expected device type %s, got %s", tt.expectedDevice, clientInfo.DeviceType)
|
||||
}
|
||||
if clientInfo.Os != tt.expectedOS {
|
||||
t.Errorf("Expected OS %s, got %s", tt.expectedOS, clientInfo.Os)
|
||||
}
|
||||
if clientInfo.Browser != tt.expectedBrowser {
|
||||
t.Errorf("Expected browser %s, got %s", tt.expectedBrowser, clientInfo.Browser)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractClientInfo(t *testing.T) {
|
||||
service := &APIV1Service{}
|
||||
|
||||
// Test with metadata containing user agent and IP
|
||||
md := metadata.New(map[string]string{
|
||||
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
|
||||
"x-forwarded-for": "203.0.113.1, 198.51.100.1",
|
||||
"x-real-ip": "203.0.113.1",
|
||||
})
|
||||
|
||||
ctx := metadata.NewIncomingContext(context.Background(), md)
|
||||
|
||||
clientInfo := service.extractClientInfo(ctx)
|
||||
|
||||
if clientInfo.UserAgent == "" {
|
||||
t.Error("Expected user agent to be set")
|
||||
}
|
||||
if clientInfo.IpAddress != "203.0.113.1" {
|
||||
t.Errorf("Expected IP address to be 203.0.113.1, got %s", clientInfo.IpAddress)
|
||||
}
|
||||
if clientInfo.DeviceType != "desktop" {
|
||||
t.Errorf("Expected device type to be desktop, got %s", clientInfo.DeviceType)
|
||||
}
|
||||
if clientInfo.Os != "Windows 10/11" {
|
||||
t.Errorf("Expected OS to be Windows 10/11, got %s", clientInfo.Os)
|
||||
}
|
||||
if clientInfo.Browser != "Chrome 119.0.0.0" {
|
||||
t.Errorf("Expected browser to be Chrome 119.0.0.0, got %s", clientInfo.Browser)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClientInfoExamples demonstrates the enhanced client info extraction with various user agents
|
||||
func TestClientInfoExamples(t *testing.T) {
|
||||
service := &APIV1Service{}
|
||||
|
||||
examples := []struct {
|
||||
description string
|
||||
userAgent string
|
||||
}{
|
||||
{
|
||||
description: "Modern Chrome on Windows 11",
|
||||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
},
|
||||
{
|
||||
description: "Safari on iPhone 15 Pro",
|
||||
userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1",
|
||||
},
|
||||
{
|
||||
description: "Chrome on Samsung Galaxy",
|
||||
userAgent: "Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
|
||||
},
|
||||
{
|
||||
description: "Firefox on Ubuntu",
|
||||
userAgent: "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/120.0",
|
||||
},
|
||||
{
|
||||
description: "Edge on Windows 10",
|
||||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0",
|
||||
},
|
||||
{
|
||||
description: "Safari on iPad Air",
|
||||
userAgent: "Mozilla/5.0 (iPad; CPU OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, example := range examples {
|
||||
t.Run(example.description, func(t *testing.T) {
|
||||
clientInfo := &storepb.SessionsUserSetting_ClientInfo{}
|
||||
service.parseUserAgent(example.userAgent, clientInfo)
|
||||
|
||||
t.Logf("User Agent: %s", example.userAgent)
|
||||
t.Logf("Device Type: %s", clientInfo.DeviceType)
|
||||
t.Logf("Operating System: %s", clientInfo.Os)
|
||||
t.Logf("Browser: %s", clientInfo.Browser)
|
||||
t.Logf("---")
|
||||
|
||||
// Ensure all fields are populated
|
||||
if clientInfo.DeviceType == "" {
|
||||
t.Error("Device type should not be empty")
|
||||
}
|
||||
if clientInfo.Os == "" {
|
||||
t.Error("OS should not be empty")
|
||||
}
|
||||
if clientInfo.Browser == "" {
|
||||
t.Error("Browser should not be empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -0,0 +1,177 @@
|
||||
import { Button } from "@usememos/mui";
|
||||
import { ClockIcon, MonitorIcon, SmartphoneIcon, TabletIcon, TrashIcon, WifiIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { userServiceClient } from "@/grpcweb";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { UserSession } from "@/types/proto/api/v1/user_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import LearnMore from "../LearnMore";
|
||||
|
||||
const listUserSessions = async (parent: string) => {
|
||||
const { sessions } = await userServiceClient.listUserSessions({ parent });
|
||||
return sessions.sort((a, b) => (b.lastAccessedTime?.getTime() ?? 0) - (a.lastAccessedTime?.getTime() ?? 0));
|
||||
};
|
||||
|
||||
const UserSessionsSection = () => {
|
||||
const t = useTranslate();
|
||||
const currentUser = useCurrentUser();
|
||||
const [userSessions, setUserSessions] = useState<UserSession[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
listUserSessions(currentUser.name).then((sessions) => {
|
||||
setUserSessions(sessions);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleRevokeSession = async (userSession: UserSession) => {
|
||||
const formattedSessionId = getFormattedSessionId(userSession.sessionId);
|
||||
const confirmed = window.confirm(t("setting.user-sessions-section.session-revocation", { sessionId: formattedSessionId }));
|
||||
if (confirmed) {
|
||||
await userServiceClient.revokeUserSession({ name: userSession.name });
|
||||
setUserSessions(userSessions.filter((session) => session.sessionId !== userSession.sessionId));
|
||||
toast.success(t("setting.user-sessions-section.session-revoked"));
|
||||
}
|
||||
};
|
||||
|
||||
const getFormattedSessionId = (sessionId: string) => {
|
||||
return `${sessionId.slice(0, 8)}...${sessionId.slice(-8)}`;
|
||||
};
|
||||
|
||||
const getDeviceIcon = (deviceType: string) => {
|
||||
switch (deviceType?.toLowerCase()) {
|
||||
case "mobile":
|
||||
return <SmartphoneIcon className="w-4 h-4 text-gray-500" />;
|
||||
case "tablet":
|
||||
return <TabletIcon className="w-4 h-4 text-gray-500" />;
|
||||
case "desktop":
|
||||
default:
|
||||
return <MonitorIcon className="w-4 h-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const formatLocation = (clientInfo: UserSession["clientInfo"]) => {
|
||||
if (!clientInfo) return "Unknown";
|
||||
|
||||
const parts = [];
|
||||
if (clientInfo.ipAddress) parts.push(clientInfo.ipAddress);
|
||||
|
||||
return parts.length > 0 ? parts.join(" • ") : "Unknown";
|
||||
};
|
||||
|
||||
const formatDeviceInfo = (clientInfo: UserSession["clientInfo"]) => {
|
||||
if (!clientInfo) return "Unknown Device";
|
||||
|
||||
const parts = [];
|
||||
if (clientInfo.os) parts.push(clientInfo.os);
|
||||
if (clientInfo.browser) parts.push(clientInfo.browser);
|
||||
|
||||
return parts.length > 0 ? parts.join(" • ") : "Unknown Device";
|
||||
};
|
||||
|
||||
const isCurrentSession = (session: UserSession) => {
|
||||
// A simple heuristic: the most recently accessed session is likely the current one
|
||||
if (userSessions.length === 0) return false;
|
||||
const mostRecent = userSessions[0];
|
||||
return session.sessionId === mostRecent.sessionId;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6 w-full flex flex-col justify-start items-start space-y-4">
|
||||
<div className="w-full">
|
||||
<div className="sm:flex sm:items-center sm:justify-between">
|
||||
<div className="sm:flex-auto space-y-1">
|
||||
<p className="flex flex-row justify-start items-center font-medium text-gray-700 dark:text-gray-400">
|
||||
{t("setting.user-sessions-section.title")}
|
||||
<LearnMore className="ml-2" url="https://usememos.com/docs/security/sessions" />
|
||||
</p>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-500">{t("setting.user-sessions-section.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full mt-2 flow-root">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="inline-block min-w-full border border-zinc-200 rounded-lg align-middle dark:border-zinc-600">
|
||||
<table className="min-w-full divide-y divide-gray-300 dark:divide-zinc-600">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="px-3 py-2 text-left text-sm font-semibold text-gray-900 dark:text-gray-400">
|
||||
{t("setting.user-sessions-section.device")}
|
||||
</th>
|
||||
<th scope="col" className="py-2 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 dark:text-gray-400">
|
||||
{t("setting.user-sessions-section.location")}
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-2 text-left text-sm font-semibold text-gray-900 dark:text-gray-400">
|
||||
{t("setting.user-sessions-section.last-active")}
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-2 text-left text-sm font-semibold text-gray-900 dark:text-gray-400">
|
||||
{t("setting.user-sessions-section.expires")}
|
||||
</th>
|
||||
<th scope="col" className="relative py-3.5 pl-3 pr-4">
|
||||
<span className="sr-only">{t("common.delete")}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-zinc-700">
|
||||
{userSessions.map((userSession) => (
|
||||
<tr key={userSession.sessionId}>
|
||||
<td className="whitespace-nowrap px-3 py-2 text-sm text-gray-900 dark:text-gray-400">
|
||||
<div className="flex items-center space-x-3">
|
||||
{getDeviceIcon(userSession.clientInfo?.deviceType || "")}
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">
|
||||
{formatDeviceInfo(userSession.clientInfo)}
|
||||
{isCurrentSession(userSession) && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100">
|
||||
<WifiIcon className="w-3 h-3 mr-1" />
|
||||
{t("setting.user-sessions-section.current")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 font-mono">{getFormattedSessionId(userSession.sessionId)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-2 pl-4 pr-3 text-sm text-gray-900 dark:text-gray-400">
|
||||
{formatLocation(userSession.clientInfo)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center space-x-1">
|
||||
<ClockIcon className="w-4 h-4" />
|
||||
<span>{userSession.lastAccessedTime?.toLocaleString()}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{userSession.expireTime?.toLocaleString() ?? t("setting.user-sessions-section.never")}
|
||||
</td>
|
||||
<td className="relative whitespace-nowrap py-2 pl-3 pr-4 text-right text-sm">
|
||||
<Button
|
||||
variant="plain"
|
||||
disabled={isCurrentSession(userSession)}
|
||||
onClick={() => {
|
||||
handleRevokeSession(userSession);
|
||||
}}
|
||||
title={
|
||||
isCurrentSession(userSession)
|
||||
? t("setting.user-sessions-section.cannot-revoke-current")
|
||||
: t("setting.user-sessions-section.revoke-session")
|
||||
}
|
||||
>
|
||||
<TrashIcon className={`w-4 h-auto ${isCurrentSession(userSession) ? "text-gray-400" : "text-red-600"}`} />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{userSessions.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">{t("setting.user-sessions-section.no-sessions")}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserSessionsSection;
|
Loading…
Reference in New Issue