feat(user): add login status tracking, last login timestamp, and UI indicators
This commit is contained in:
parent
0e89c44448
commit
1e7e40b8a6
8 changed files with 93 additions and 35 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
39
auth.ts
|
|
@ -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: "/" });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
Loading…
Reference in a new issue