Просмотр исходного кода

add last seen in users table (#6)

Bernt Christian Egeland 7 месяцев назад
Родитель
Сommit
6eb568b13e

+ 3 - 0
prisma/migrations/20260221055301_user_last_seen/migration.sql

@@ -0,0 +1,3 @@
+-- AlterTable
+ALTER TABLE "users" ADD COLUMN     "lastLogin" TIMESTAMP(3),
+ADD COLUMN     "lastSeen" TIMESTAMP(3);

+ 2 - 0
prisma/schema.prisma

@@ -19,6 +19,8 @@ model User {
 
   twoFactorEnabled Boolean   @default(false)
   termsAcceptedAt  DateTime?
+  lastLogin        DateTime?
+  lastSeen         DateTime?
 
   sessions               Session[]
   accounts               Account[]

+ 2 - 0
src/app/(authenticated)/layout.tsx

@@ -10,6 +10,7 @@ import { WhiteLabelCtaProvider } from "@/components/white-label-cta-context";
 import { DateSettingsProvider } from "@/components/date-settings-context";
 import { getCachedMembership } from "@/lib/cached-session";
 import { hasPermission, PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { OnlineTracker } from "@/components/online-tracker";
 
 export default async function DashboardLayout({
   children,
@@ -75,6 +76,7 @@ export default async function DashboardLayout({
         <SidebarInset>{children}</SidebarInset>
         <SearchCommand />
         {isOwnerOrAdmin && <NotificationInitializer />}
+        <OnlineTracker />
       </ConfirmProvider>
       </DateSettingsProvider>
     </SidebarProvider>

+ 16 - 0
src/app/api/ping/route.ts

@@ -0,0 +1,16 @@
+import { NextResponse } from "next/server";
+import { headers } from "next/headers";
+import { auth } from "@/lib/auth";
+import { db } from "@/lib/db";
+
+export async function POST() {
+  const session = await auth.api.getSession({ headers: await headers() });
+  if (!session?.user?.id) return NextResponse.json({ ok: false }, { status: 401 });
+
+  await db.user.update({
+    where: { id: session.user.id },
+    data: { lastSeen: new Date() },
+  });
+
+  return NextResponse.json({ ok: true });
+}

+ 23 - 0
src/components/online-tracker.tsx

@@ -0,0 +1,23 @@
+"use client";
+import { useEffect } from "react";
+
+export function OnlineTracker() {
+  useEffect(() => {
+    const ping = () => {
+      if (document.visibilityState === "visible") {
+        fetch("/api/ping", { method: "POST" });
+      }
+    };
+
+    ping(); // ping immediately on mount
+    const interval = setInterval(ping, 60_000); // every 60 seconds
+    document.addEventListener("visibilitychange", ping); // ping on tab focus
+
+    return () => {
+      clearInterval(interval);
+      document.removeEventListener("visibilitychange", ping);
+    };
+  }, []);
+
+  return null;
+}

+ 2 - 0
src/features/admin/Actions/getUsers.ts

@@ -26,6 +26,7 @@ export async function getUsers(input?: { search?: string; page?: number; pageSiz
           email: true,
           isSuperAdmin: true,
           createdAt: true,
+          lastSeen: true,
         },
         orderBy: { createdAt: "desc" },
         skip: (page - 1) * pageSize,
@@ -50,6 +51,7 @@ export async function getUsers(input?: { search?: string; page?: number; pageSiz
         email: u.email,
         isSuperAdmin: u.isSuperAdmin,
         createdAt: u.createdAt.toISOString(),
+        lastSeen: u.lastSeen?.toISOString() ?? null,
         organizationCount: countMap.get(u.id) ?? 0,
       })),
       total,

+ 27 - 1
src/features/admin/Components/admin-users.tsx

@@ -32,9 +32,22 @@ type UserRow = {
   email: string;
   isSuperAdmin: boolean;
   createdAt: string;
+  lastSeen: string | null;
   organizationCount: number;
 };
 
+function OnlineDot({ lastSeen }: { lastSeen: string | null }) {
+  if (!lastSeen) return null;
+  const isOnline = Date.now() - new Date(lastSeen).getTime() < 5 * 60 * 1000;
+  if (!isOnline) return null;
+  return (
+    <span className="relative flex h-2 w-2" title="Online">
+      <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75" />
+      <span className="relative inline-flex h-2 w-2 rounded-full bg-green-500" />
+    </span>
+  );
+}
+
 type PaginatedData = {
   users: UserRow[];
   total: number;
@@ -156,13 +169,14 @@ export function AdminUsers({
               <TableHead>Role</TableHead>
               <TableHead className="text-center">Orgs</TableHead>
               <TableHead>Created</TableHead>
+              <TableHead>Last Seen</TableHead>
               <TableHead className="w-10" />
             </TableRow>
           </TableHeader>
           <TableBody>
             {data.users.length === 0 ? (
               <TableRow>
-                <TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
+                <TableCell colSpan={7} className="h-32 text-center text-muted-foreground">
                   {search ? "No users match your search." : "No users found."}
                 </TableCell>
               </TableRow>
@@ -185,6 +199,18 @@ export function AdminUsers({
                   <TableCell>
                     {new Date(user.createdAt).toLocaleDateString()}
                   </TableCell>
+                  <TableCell>
+                    {user.lastSeen && Date.now() - new Date(user.lastSeen).getTime() < 5 * 60 * 1000 ? (
+                      <span className="flex items-center gap-1.5">
+                        <OnlineDot lastSeen={user.lastSeen} />
+                        <span className="text-sm text-green-600 dark:text-green-400">Online</span>
+                      </span>
+                    ) : (
+                      <span className="text-sm text-muted-foreground">
+                        {user.lastSeen ? new Date(user.lastSeen).toLocaleString() : "—"}
+                      </span>
+                    )}
+                  </TableCell>
                   <TableCell>
                     <DropdownMenu>
                       <DropdownMenuTrigger asChild>

+ 10 - 0
src/lib/auth.ts

@@ -56,6 +56,16 @@ export const auth = betterAuth({
     useSecureCookies: isProduction,
   },
   databaseHooks: {
+    session: {
+      create: {
+        after: async (session) => {
+          await db.user.update({
+            where: { id: session.userId },
+            data: { lastLogin: new Date() },
+          });
+        },
+      },
+    },
     user: {
       create: {
         before: async (user) => {