feat(user): add login status tracking, last login timestamp, and UI indicators

This commit is contained in:
Chneemann 2026-08-13 08:34:21 +02:00
parent 0e89c44448
commit 1e7e40b8a6
No known key found for this signature in database
8 changed files with 93 additions and 35 deletions

View file

@ -24,7 +24,7 @@ interface UserAvatarProps {
}
/**
* Renders a single user avatar circle with optional crown badge.
* Renders a single user avatar circle with online status and optional crown badge.
*
* @param {UserAvatarProps} props - The component props.
* @returns {JSX.Element} The rendered user avatar component.
@ -33,6 +33,7 @@ function UserAvatar({ user, title, isCreator }: UserAvatarProps) {
const fullName = `${user.firstName} ${user.lastName}`;
const initials = getInitials(user.firstName, user.lastName);
const bgColor = user.color;
const isOnline = user.isOnline;
return (
<div
@ -46,6 +47,10 @@ function UserAvatar({ user, title, isCreator }: UserAvatarProps) {
{initials}
</div>
{isOnline && (
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full border-2 border-card bg-emerald-500" />
)}
{isCreator && (
<span className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-amber-600 rounded-full border-2 border-border flex items-center justify-center shadow-sm">
<Crown

View file

@ -9,25 +9,19 @@ import { useState } from "react";
import Card from "../card/Card";
import ColumnHeader from "./ColumnHeader";
import ColumnEmptyState from "./ColumnEmptyState";
import { Task, TaskStatus } from "@/types/task";
import { ColumnConfig, Task, TaskStatus } from "@/types/task";
/**
* Properties for the Column component.
*
* @interface ColumnProps
* @property {TaskStatus} id - The unique status identifier for the column.
* @property {string} title - The display title of the column.
* @property {string} color - The indicator color styling for the column header.
* @property {number} count - The total count of tasks within this column.
* @property {Task[]} tasks - The array of tasks belonging to this column.
* @property {Set<string>} [updatingTaskIds] - A set of task IDs currently undergoing updates.
* @property {(taskId: string, targetStatus: TaskStatus) => void} [onTaskMove] - Callback triggered when a task is moved to a new status column.
* @property {(taskId: string) => void} [onTaskDelete] - Callback triggered when a task deletion is requested.
*/
export interface ColumnProps {
id: TaskStatus;
title: string;
color: string;
export interface ColumnProps extends ColumnConfig {
count: number;
tasks: Task[];
updatingTaskIds?: Set<string>;

View file

@ -1,6 +1,6 @@
/**
* @file UserBadge.tsx
* @description Server component rendering the authenticated user profile badge with initials and name.
* @description Server component rendering the authenticated user profile badge with initials, name, and online status.
*/
import { auth } from "@/auth";
@ -8,7 +8,7 @@ import { UserService } from "@/services/user.service";
import { getInitials } from "@/utils/user";
/**
* Renders a user profile badge including an avatar initial circle with user color and full name.
* Renders a user profile badge including an avatar initial circle with user color, full name, and online status indicator.
*
* @async
* @returns {Promise<JSX.Element | null>} The rendered user badge component, or null if unauthenticated.
@ -22,19 +22,41 @@ export default async function UserBadge() {
const fullName = `${user.firstName} ${user.lastName}`.trim();
const initials = getInitials(user.firstName, user.lastName);
const bgColor = user.color || "bg-primary";
const bgColor = user.color;
const lastLogin = user.lastLogin;
const formattedLastLogin = new Date(lastLogin!).toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
return (
<div className="flex items-center gap-3">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center text-lg font-black tracking-wider text-white shadow-md border-2 border-card ring-2 ring-border/50 shrink-0 ${bgColor}`}
style={{ textShadow: "0 1px 2px rgba(0, 0, 0, 0.8)" }}
>
{initials}
<div
className="flex items-center gap-3"
title={`Last Login: ${formattedLastLogin}`}
>
<div className="relative shrink-0">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center text-lg font-black tracking-wider text-white shadow-md border-2 border-card ring-2 ring-border/50 ${bgColor}`}
style={{ textShadow: "0 1px 2px rgba(0, 0, 0, 0.8)" }}
>
{initials}
</div>
<span
className="absolute bottom-0 right-0 w-3 h-3 rounded-full border-2 border-card bg-emerald-500"
title="Online"
/>
</div>
<div className="hidden sm:block text-left">
<p className="text-sm font-medium leading-tight">{fullName}</p>
<p className="text-foreground-muted text-xs">Online</p>
<p className="text-foreground-muted text-xs flex items-center gap-1.5">
Online
</p>
</div>
</div>
);

View file

@ -1,24 +1,19 @@
/**
* @file SignOutButton.tsx
* @description Server Action-powered client component that allows users to sign out and redirect to the home page.
* @description Server Action-powered client component rendering a sign-out button that triggers session termination via a server action.
*/
import { signOut } from "@/auth";
import { handleSignOut } from "@/auth";
import { LogOut } from "lucide-react";
/**
* Renders a sign-out button using a Server Action to securely terminate the user session.
* Renders a form containing a submit button to securely sign out the current user session using a server action.
*
* @returns {JSX.Element} The rendered sign-out button component.
*/
export default function SignOutButton() {
return (
<form
action={async () => {
"use server";
await signOut({ redirectTo: "/" });
}}
>
<form action={handleSignOut}>
<button
type="submit"
className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium hover:text-primary-hover transition-colors w-full cursor-pointer"

39
auth.ts
View file

@ -1,6 +1,6 @@
/**
* @file auth.ts
* @description NextAuth configuration file setting up credentials authentication, database lookups via Drizzle, JWT sessions, and custom pages.
* @description NextAuth configuration file setting up credentials authentication, database lookups via Drizzle, JWT sessions, custom pages, and a secure server-side sign out handler.
*/
import NextAuth from "next-auth";
@ -21,11 +21,12 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
password: { label: "Password", type: "password" },
},
/**
* Authorizes a user by verifying their email and comparing hashed passwords from the database.
* Authorizes a user by verifying their email and comparing hashed passwords from the database,
* updating login tracking info upon success.
*
* @async
* @param {Record<string, any>} [credentials] - The user credentials submitted via the sign-in form.
* @returns {Promise<{ id: string; email: string } | null>} The authenticated user object or null if authorization fails.
* @returns {Promise<{ id: string } | null>} The authenticated user object containing the ID or null if authorization fails.
*/
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;
@ -43,7 +44,15 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
if (!passwordsMatch) return null;
return { id: String(user.id), email: user.email };
await db
.update(usersTable)
.set({
lastLogin: new Date(),
isOnline: true,
})
.where(eq(usersTable.id, user.id));
return { id: String(user.id) };
},
}),
],
@ -86,3 +95,25 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
},
},
});
/**
* Handles the secure logout process, setting the user's online status to false in the database
* and destroying the active session.
*
* @async
* @returns {Promise<void>}
*/
export async function handleSignOut() {
"use server";
const session = await auth();
if (session?.user?.id) {
await db
.update(usersTable)
.set({ isOnline: false })
.where(eq(usersTable.id, session.user.id));
}
await signOut({ redirectTo: "/" });
}

View file

@ -4,6 +4,7 @@
*/
import {
boolean,
pgEnum,
pgTable,
primaryKey,
@ -49,6 +50,8 @@ export const usersTable = pgTable("users", {
firstName: text("first_name").notNull(),
lastName: text("last_name").notNull(),
color: text("color").default("bg-indigo-500").notNull(),
lastLogin: timestamp("last_login"),
isOnline: boolean("is_online").default(false).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
});

View file

@ -12,11 +12,11 @@ import { eq } from "drizzle-orm";
*/
export class UserService {
/**
* Retrieves specific profile information (firstName, lastName, color) for a user by ID.
* Retrieves specific profile information (firstName, lastName, color, online status) for a user by ID.
*
* @async
* @param {string} userId - The unique identifier of the user.
* @returns {Promise<{ firstName: string; lastName: string; color: string } | null>} The user profile data or null.
* @returns {Promise<{ firstName: string; lastName: string; color: string; isOnline: boolean; lastLogin: Date } | null>} The user profile data or null.
*/
static async findProfileById(userId: string) {
const [user] = await db
@ -24,6 +24,8 @@ export class UserService {
firstName: usersTable.firstName,
lastName: usersTable.lastName,
color: usersTable.color,
isOnline: usersTable.isOnline,
lastLogin: usersTable.lastLogin,
})
.from(usersTable)
.where(eq(usersTable.id, userId));

View file

@ -33,6 +33,12 @@ export interface TaskPriorityConfig {
className: string;
}
export interface ColumnConfig {
id: TaskStatus;
title: string;
color: string;
}
export interface RouteContext {
params: Promise<{ id: string }>;
}
@ -61,4 +67,4 @@ export const COLUMNS = [
{ id: "in_progress", title: "In Progress", color: "bg-indigo-500" },
{ id: "await_feedback", title: "Await Feedback", color: "bg-amber-500" },
{ id: "done", title: "Done", color: "bg-emerald-500" },
] as const;
] as const satisfies readonly ColumnConfig[];