From c6d2f0c6cc06bd15a37bf16593619a141d8fd889 Mon Sep 17 00:00:00 2001
From: Philipinho <16838612+Philipinho@users.noreply.github.com>
Date: Thu, 26 Mar 2026 00:21:59 +0000
Subject: [PATCH] user session management
---
.../public/locales/en-US/translation.json | 10 +-
.../session/components/session-list.tsx | 165 ++++++++++++++++++
.../features/session/queries/session-query.ts | 55 ++++++
.../session/services/session-service.ts | 17 ++
.../features/session/types/session.types.ts | 8 +
.../user/components/account-name-form.tsx | 10 +-
.../settings/account/account-settings.tsx | 5 +
apps/server/package.json | 1 +
.../middlewares/audit-context.middleware.ts | 5 +
apps/server/src/core/auth/auth.controller.ts | 24 ++-
apps/server/src/core/auth/dto/jwt-payload.ts | 1 +
.../src/core/auth/services/auth.service.ts | 25 ++-
.../src/core/auth/services/token.service.ts | 3 +-
.../src/core/auth/strategies/jwt.strategy.ts | 14 ++
apps/server/src/core/core.module.ts | 2 +
.../core/session/dto/revoke-session.dto.ts | 7 +
.../core/session/session-activity.service.ts | 36 ++++
.../src/core/session/session.controller.ts | 80 +++++++++
.../server/src/core/session/session.module.ts | 14 ++
.../src/core/session/session.service.ts | 111 ++++++++++++
.../services/workspace-invitation.service.ts | 4 +-
.../workspace/services/workspace.service.ts | 6 +
apps/server/src/database/database.module.ts | 3 +
.../20260325T120000-user-sessions.ts | 44 +++++
.../repos/session/user-session.repo.ts | 125 +++++++++++++
apps/server/src/database/types/db.d.ts | 15 ++
.../server/src/database/types/entity.types.ts | 6 +
apps/server/src/ee | 2 +-
pnpm-lock.yaml | 11 +-
29 files changed, 788 insertions(+), 21 deletions(-)
create mode 100644 apps/client/src/features/session/components/session-list.tsx
create mode 100644 apps/client/src/features/session/queries/session-query.ts
create mode 100644 apps/client/src/features/session/services/session-service.ts
create mode 100644 apps/client/src/features/session/types/session.types.ts
create mode 100644 apps/server/src/core/session/dto/revoke-session.dto.ts
create mode 100644 apps/server/src/core/session/session-activity.service.ts
create mode 100644 apps/server/src/core/session/session.controller.ts
create mode 100644 apps/server/src/core/session/session.module.ts
create mode 100644 apps/server/src/core/session/session.service.ts
create mode 100644 apps/server/src/database/migrations/20260325T120000-user-sessions.ts
create mode 100644 apps/server/src/database/repos/session/user-session.repo.ts
diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index f35ec179..a814d7e3 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -708,5 +708,13 @@
"Resend verification email": "Resend verification email",
"Verification email sent. Please check your inbox.": "Verification email sent. Please check your inbox.",
"Failed to resend verification email. Please try again.": "Failed to resend verification email. Please try again.",
- "We've sent you an email with your associated workspaces.": "We've sent you an email with your associated workspaces."
+ "We've sent you an email with your associated workspaces.": "We've sent you an email with your associated workspaces.",
+ "Load more": "Load more",
+ "Log out of all devices": "Log out of all devices",
+ "Log out of all sessions except this device": "Log out of all sessions except this device",
+ "This Device": "This Device",
+ "Unknown device": "Unknown device",
+ "No active sessions": "No active sessions",
+ "Session revoked": "Session revoked",
+ "All other sessions revoked": "All other sessions revoked"
}
diff --git a/apps/client/src/features/session/components/session-list.tsx b/apps/client/src/features/session/components/session-list.tsx
new file mode 100644
index 00000000..34e39198
--- /dev/null
+++ b/apps/client/src/features/session/components/session-list.tsx
@@ -0,0 +1,165 @@
+import { useState } from "react";
+import {
+ Button,
+ Divider,
+ Group,
+ Skeleton,
+ Stack,
+ Table,
+ Text,
+} from "@mantine/core";
+import { IconDevices } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import {
+ useGetSessionsQuery,
+ useRevokeSessionMutation,
+ useRevokeAllSessionsMutation,
+} from "@/features/session/queries/session-query";
+import { formattedDate } from "@/lib/time";
+
+const PAGE_SIZE = 5;
+
+export default function SessionList() {
+ const { t } = useTranslation();
+ const { data: sessions, isLoading } = useGetSessionsQuery();
+ const revokeSessionMutation = useRevokeSessionMutation();
+ const revokeAllSessionsMutation = useRevokeAllSessionsMutation();
+ const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
+
+ const otherSessions = sessions?.filter((s) => !s.isCurrent) ?? [];
+ const visibleSessions = sessions?.slice(0, visibleCount) ?? [];
+ const hasMore = sessions && visibleCount < sessions.length;
+
+ if (isLoading) {
+ return (
+
+
+
+ {t("Device Name")}
+ {t("Last Active")}
+
+
+
+
+ {[1, 2, 3].map((i) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+ }
+
+ return (
+
+ {otherSessions.length > 0 && (
+ <>
+
+ {t("Log out of all devices")}
+
+
+ {t(
+ "Log out of all sessions except this device",
+ )}
+
+
+
+
+
+ >
+ )}
+
+
+
+
+ {t("Device Name")}
+ {t("Last Active")}
+ {otherSessions.length > 0 && }
+
+
+
+ {visibleSessions.map((session) => (
+
+
+
+
+
+
+ {session.deviceName || t("Unknown device")}
+
+ {session.isCurrent && (
+
+ {t("This Device")}
+
+ )}
+
+
+
+
+
+ {session.isCurrent
+ ? t("Now")
+ : formattedDate(new Date(session.lastActiveAt))}
+
+
+ {otherSessions.length > 0 && (
+
+ {!session.isCurrent && (
+
+ )}
+
+ )}
+
+ ))}
+
+
+
+ {hasMore && (
+
+ )}
+
+ {(!sessions || sessions.length === 0) && (
+
+ {t("No active sessions")}
+
+ )}
+
+ );
+}
diff --git a/apps/client/src/features/session/queries/session-query.ts b/apps/client/src/features/session/queries/session-query.ts
new file mode 100644
index 00000000..3104ce2b
--- /dev/null
+++ b/apps/client/src/features/session/queries/session-query.ts
@@ -0,0 +1,55 @@
+import {
+ useMutation,
+ useQuery,
+ useQueryClient,
+ UseQueryResult,
+} from "@tanstack/react-query";
+import {
+ getSessions,
+ revokeSession,
+ revokeAllSessions,
+} from "@/features/session/services/session-service";
+import { ISession } from "@/features/session/types/session.types";
+import { notifications } from "@mantine/notifications";
+import { useTranslation } from "react-i18next";
+
+export function useGetSessionsQuery(): UseQueryResult {
+ return useQuery({
+ queryKey: ["session-list"],
+ queryFn: () => getSessions(),
+ });
+}
+
+export function useRevokeSessionMutation() {
+ const queryClient = useQueryClient();
+ const { t } = useTranslation();
+
+ return useMutation({
+ mutationFn: (data) => revokeSession(data),
+ onSuccess: () => {
+ notifications.show({ message: t("Session revoked") });
+ queryClient.invalidateQueries({ queryKey: ["session-list"] });
+ },
+ onError: (error) => {
+ const errorMessage = error["response"]?.data?.message;
+ notifications.show({ message: errorMessage, color: "red" });
+ },
+ });
+}
+
+export function useRevokeAllSessionsMutation() {
+ const queryClient = useQueryClient();
+ const { t } = useTranslation();
+
+ return useMutation({
+ mutationFn: () => revokeAllSessions(),
+ onSuccess: () => {
+ notifications.show({ message: t("All other sessions revoked") });
+ queryClient.invalidateQueries({ queryKey: ["session-list"] });
+ },
+ onError: (error) => {
+ const errorMessage = error["response"]?.data?.message;
+ notifications.show({ message: errorMessage, color: "red" });
+ },
+ });
+}
diff --git a/apps/client/src/features/session/services/session-service.ts b/apps/client/src/features/session/services/session-service.ts
new file mode 100644
index 00000000..6f2bde83
--- /dev/null
+++ b/apps/client/src/features/session/services/session-service.ts
@@ -0,0 +1,17 @@
+import api from "@/lib/api-client";
+import { ISession } from "@/features/session/types/session.types";
+
+export async function getSessions(): Promise {
+ const req = await api.post<{ sessions: ISession[] }>("/sessions");
+ return req.data.sessions;
+}
+
+export async function revokeSession(data: {
+ sessionId: string;
+}): Promise {
+ await api.post("/sessions/revoke", data);
+}
+
+export async function revokeAllSessions(): Promise {
+ await api.post("/sessions/revoke-all");
+}
diff --git a/apps/client/src/features/session/types/session.types.ts b/apps/client/src/features/session/types/session.types.ts
new file mode 100644
index 00000000..ed0a3903
--- /dev/null
+++ b/apps/client/src/features/session/types/session.types.ts
@@ -0,0 +1,8 @@
+export type ISession = {
+ id: string;
+ deviceName: string | null;
+ geoLocation: string | null;
+ lastActiveAt: string;
+ createdAt: string;
+ isCurrent: boolean;
+};
diff --git a/apps/client/src/features/user/components/account-name-form.tsx b/apps/client/src/features/user/components/account-name-form.tsx
index 98929b04..70a5b52c 100644
--- a/apps/client/src/features/user/components/account-name-form.tsx
+++ b/apps/client/src/features/user/components/account-name-form.tsx
@@ -1,9 +1,8 @@
import { useAtom } from "jotai";
-import { focusAtom } from "jotai-optics";
import { z } from "zod/v4";
import { useForm } from "@mantine/form";
import { zod4Resolver } from "mantine-form-zod-resolver";
-import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
+import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
import { updateUser } from "@/features/user/services/user-service.ts";
import { IUser } from "@/features/user/types/user.types.ts";
import { useState } from "react";
@@ -17,18 +16,15 @@ const formSchema = z.object({
type FormValues = z.infer;
-const userAtom = focusAtom(currentUserAtom, (optic) => optic.prop("user"));
-
export default function AccountNameForm() {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
- const [currentUser] = useAtom(currentUserAtom);
- const [, setUser] = useAtom(userAtom);
+ const [user, setUser] = useAtom(userAtom);
const form = useForm({
validate: zod4Resolver(formSchema),
initialValues: {
- name: currentUser?.user.name,
+ name: user?.name,
},
});
diff --git a/apps/client/src/pages/settings/account/account-settings.tsx b/apps/client/src/pages/settings/account/account-settings.tsx
index f1d78f7d..6f87d31a 100644
--- a/apps/client/src/pages/settings/account/account-settings.tsx
+++ b/apps/client/src/pages/settings/account/account-settings.tsx
@@ -8,6 +8,7 @@ import { getAppName } from "@/lib/config.ts";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { AccountMfaSection } from "@/features/user/components/account-mfa-section";
+import SessionList from "@/features/session/components/session-list";
export default function AccountSettings() {
const { t } = useTranslation();
@@ -36,6 +37,10 @@ export default function AccountSettings() {
+
+
+
+
>
);
}
diff --git a/apps/server/package.json b/apps/server/package.json
index 53fd6468..94a9c935 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -66,6 +66,7 @@
"ai": "^6.0.134",
"ai-sdk-ollama": "^3.8.1",
"bcrypt": "^6.0.0",
+ "bowser": "^2.14.1",
"bullmq": "^5.71.0",
"cache-manager": "^7.2.8",
"cheerio": "^1.2.0",
diff --git a/apps/server/src/common/middlewares/audit-context.middleware.ts b/apps/server/src/common/middlewares/audit-context.middleware.ts
index d58c4353..f5066535 100644
--- a/apps/server/src/common/middlewares/audit-context.middleware.ts
+++ b/apps/server/src/common/middlewares/audit-context.middleware.ts
@@ -7,6 +7,7 @@ export interface AuditContext {
actorId: string | null;
actorType: 'user' | 'system' | 'api_key';
ipAddress: string | null;
+ userAgent: string | null;
}
export const AUDIT_CONTEXT_KEY = 'auditContext';
@@ -19,11 +20,15 @@ export class AuditContextMiddleware implements NestMiddleware {
const workspaceId = (req as any).workspaceId ?? null;
const ipAddress = this.extractIpAddress(req);
+ const userAgent =
+ (req.headers['user-agent'] as string) ?? null;
+
const auditContext: AuditContext = {
workspaceId,
actorId: null,
actorType: 'user',
ipAddress,
+ userAgent,
};
this.cls.set(AUDIT_CONTEXT_KEY, auditContext);
diff --git a/apps/server/src/core/auth/auth.controller.ts b/apps/server/src/core/auth/auth.controller.ts
index f83fc1cf..c0997e75 100644
--- a/apps/server/src/core/auth/auth.controller.ts
+++ b/apps/server/src/core/auth/auth.controller.ts
@@ -5,12 +5,14 @@ import {
HttpStatus,
Inject,
Post,
+ Req,
Res,
UseGuards,
Logger,
} from '@nestjs/common';
import { LoginDto } from './dto/login.dto';
import { AuthService } from './services/auth.service';
+import { SessionService } from '../session/session.service';
import { SetupGuard } from './guards/setup.guard';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { CreateAdminUserDto } from './dto/create-admin-user.dto';
@@ -22,7 +24,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { PasswordResetDto } from './dto/password-reset.dto';
import { VerifyUserTokenDto } from './dto/verify-user-token.dto';
-import { FastifyReply } from 'fastify';
+import { FastifyReply, FastifyRequest } from 'fastify';
import { validateSsoEnforcement } from './auth.util';
import { ModuleRef } from '@nestjs/core';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
@@ -37,6 +39,7 @@ export class AuthController {
constructor(
private authService: AuthService,
+ private sessionService: SessionService,
private environmentService: EnvironmentService,
private moduleRef: ModuleRef,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
@@ -115,8 +118,15 @@ export class AuthController {
@Body() dto: ChangePasswordDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
+ @Req() req: FastifyRequest,
) {
- return this.authService.changePassword(dto, user.id, workspace.id);
+ const currentSessionId = (req.raw as any).sessionId;
+ return this.authService.changePassword(
+ dto,
+ user.id,
+ workspace.id,
+ currentSessionId,
+ );
}
@HttpCode(HttpStatus.OK)
@@ -178,8 +188,18 @@ export class AuthController {
@Post('logout')
async logout(
@AuthUser() user: User,
+ @Req() req: FastifyRequest,
@Res({ passthrough: true }) res: FastifyReply,
) {
+ const sessionId = (req.raw as any).sessionId;
+ if (sessionId) {
+ await this.sessionService.revokeSession(
+ sessionId,
+ user.id,
+ user.workspaceId,
+ );
+ }
+
res.clearCookie('authToken');
this.auditService.log({
diff --git a/apps/server/src/core/auth/dto/jwt-payload.ts b/apps/server/src/core/auth/dto/jwt-payload.ts
index 0f7db401..f70848b9 100644
--- a/apps/server/src/core/auth/dto/jwt-payload.ts
+++ b/apps/server/src/core/auth/dto/jwt-payload.ts
@@ -11,6 +11,7 @@ export type JwtPayload = {
email: string;
workspaceId: string;
type: 'access';
+ sessionId?: string;
};
export type JwtCollabPayload = {
diff --git a/apps/server/src/core/auth/services/auth.service.ts b/apps/server/src/core/auth/services/auth.service.ts
index 1bb2c5ee..bfd8e1a0 100644
--- a/apps/server/src/core/auth/services/auth.service.ts
+++ b/apps/server/src/core/auth/services/auth.service.ts
@@ -8,6 +8,8 @@ import {
import { LoginDto } from '../dto/login.dto';
import { CreateUserDto } from '../dto/create-user.dto';
import { TokenService } from './token.service';
+import { SessionService } from '../../session/session.service';
+import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
import { SignupService } from './signup.service';
import { CreateAdminUserDto } from '../dto/create-admin-user.dto';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
@@ -44,6 +46,8 @@ export class AuthService {
constructor(
private signupService: SignupService,
private tokenService: TokenService,
+ private sessionService: SessionService,
+ private userSessionRepo: UserSessionRepo,
private userRepo: UserRepo,
private userTokenRepo: UserTokenRepo,
private mailService: MailService,
@@ -90,19 +94,19 @@ export class AuthService {
metadata: { source: 'password' },
});
- return this.tokenService.generateAccessToken(user);
+ return this.sessionService.createSessionAndToken(user);
}
async register(createUserDto: CreateUserDto, workspaceId: string) {
const user = await this.signupService.signup(createUserDto, workspaceId);
- return this.tokenService.generateAccessToken(user);
+ return this.sessionService.createSessionAndToken(user);
}
async setup(createAdminUserDto: CreateAdminUserDto) {
const { workspace, user } =
await this.signupService.initialSetup(createAdminUserDto);
- const authToken = await this.tokenService.generateAccessToken(user);
+ const authToken = await this.sessionService.createSessionAndToken(user);
return { workspace, authToken };
}
@@ -110,6 +114,7 @@ export class AuthService {
dto: ChangePasswordDto,
userId: string,
workspaceId: string,
+ currentSessionId?: string,
): Promise {
const user = await this.userRepo.findById(userId, workspaceId, {
includePassword: true,
@@ -138,6 +143,16 @@ export class AuthService {
workspaceId,
);
+ if (currentSessionId) {
+ await this.userSessionRepo.deleteAllExceptCurrent(
+ currentSessionId,
+ userId,
+ workspaceId,
+ );
+ } else {
+ await this.userSessionRepo.deleteByUserId(userId, workspaceId);
+ }
+
this.auditService.log({
event: AuditEvent.USER_PASSWORD_CHANGED,
resourceType: AuditResource.USER,
@@ -244,6 +259,8 @@ export class AuthService {
.execute();
});
+ await this.userSessionRepo.deleteByUserId(user.id, workspace.id);
+
this.auditService.setActorId(user.id);
this.auditService.log({
event: AuditEvent.USER_PASSWORD_RESET,
@@ -276,7 +293,7 @@ export class AuthService {
};
}
- const authToken = await this.tokenService.generateAccessToken(user);
+ const authToken = await this.sessionService.createSessionAndToken(user);
return { authToken };
}
diff --git a/apps/server/src/core/auth/services/token.service.ts b/apps/server/src/core/auth/services/token.service.ts
index 182b6675..b9035ba3 100644
--- a/apps/server/src/core/auth/services/token.service.ts
+++ b/apps/server/src/core/auth/services/token.service.ts
@@ -25,7 +25,7 @@ export class TokenService {
private environmentService: EnvironmentService,
) {}
- async generateAccessToken(user: User): Promise {
+ async generateAccessToken(user: User, sessionId: string): Promise {
if (isUserDisabled(user)) {
throw new ForbiddenException();
}
@@ -35,6 +35,7 @@ export class TokenService {
email: user.email,
workspaceId: user.workspaceId,
type: JwtType.ACCESS,
+ sessionId,
};
return this.jwtService.sign(payload);
}
diff --git a/apps/server/src/core/auth/strategies/jwt.strategy.ts b/apps/server/src/core/auth/strategies/jwt.strategy.ts
index 61096245..10c9e961 100644
--- a/apps/server/src/core/auth/strategies/jwt.strategy.ts
+++ b/apps/server/src/core/auth/strategies/jwt.strategy.ts
@@ -5,6 +5,8 @@ import { EnvironmentService } from '../../../integrations/environment/environmen
import { JwtApiKeyPayload, JwtPayload, JwtType } from '../dto/jwt-payload';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
+import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
+import { SessionActivityService } from '../../session/session-activity.service';
import { FastifyRequest } from 'fastify';
import { extractBearerTokenFromHeader, isUserDisabled } from '../../../common/helpers';
import { ModuleRef } from '@nestjs/core';
@@ -16,6 +18,8 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
private userRepo: UserRepo,
private workspaceRepo: WorkspaceRepo,
+ private userSessionRepo: UserSessionRepo,
+ private sessionActivityService: SessionActivityService,
private readonly environmentService: EnvironmentService,
private moduleRef: ModuleRef,
) {
@@ -57,6 +61,16 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
throw new UnauthorizedException();
}
+ if ((payload as JwtPayload).sessionId) {
+ const sessionId = (payload as JwtPayload).sessionId;
+ const session = await this.userSessionRepo.findActiveById(sessionId);
+ if (!session || session.userId !== payload.sub || session.workspaceId !== payload.workspaceId) {
+ throw new UnauthorizedException('Session revoked');
+ }
+ req.raw.sessionId = sessionId;
+ this.sessionActivityService.trackActivity(sessionId, payload.sub, payload.workspaceId);
+ }
+
return { user, workspace };
}
diff --git a/apps/server/src/core/core.module.ts b/apps/server/src/core/core.module.ts
index 81dfc138..4cef21ed 100644
--- a/apps/server/src/core/core.module.ts
+++ b/apps/server/src/core/core.module.ts
@@ -20,6 +20,7 @@ import { AuditContextMiddleware } from '../common/middlewares/audit-context.midd
import { ShareModule } from './share/share.module';
import { NotificationModule } from './notification/notification.module';
import { WatcherModule } from './watcher/watcher.module';
+import { SessionModule } from './session/session.module';
import { ClsMiddleware } from 'nestjs-cls';
@Module({
@@ -38,6 +39,7 @@ import { ClsMiddleware } from 'nestjs-cls';
ShareModule,
NotificationModule,
WatcherModule,
+ SessionModule,
],
})
export class CoreModule implements NestModule {
diff --git a/apps/server/src/core/session/dto/revoke-session.dto.ts b/apps/server/src/core/session/dto/revoke-session.dto.ts
new file mode 100644
index 00000000..7e7bb86f
--- /dev/null
+++ b/apps/server/src/core/session/dto/revoke-session.dto.ts
@@ -0,0 +1,7 @@
+import { IsNotEmpty, IsUUID } from 'class-validator';
+
+export class RevokeSessionDto {
+ @IsUUID()
+ @IsNotEmpty()
+ sessionId: string;
+}
diff --git a/apps/server/src/core/session/session-activity.service.ts b/apps/server/src/core/session/session-activity.service.ts
new file mode 100644
index 00000000..4b9adc4e
--- /dev/null
+++ b/apps/server/src/core/session/session-activity.service.ts
@@ -0,0 +1,36 @@
+import { Injectable } from '@nestjs/common';
+import { RedisService } from '@nestjs-labs/nestjs-ioredis';
+import type { Redis } from 'ioredis';
+import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
+import { UserRepo } from '@docmost/db/repos/user/user.repo';
+
+const THROTTLE_SECONDS = 15 * 60; // 15 minutes
+
+@Injectable()
+export class SessionActivityService {
+ private readonly redis: Redis;
+
+ constructor(
+ private readonly redisService: RedisService,
+ private readonly userSessionRepo: UserSessionRepo,
+ private readonly userRepo: UserRepo,
+ ) {
+ this.redis = this.redisService.getOrThrow();
+ }
+
+ trackActivity(sessionId: string, userId: string, workspaceId: string): void {
+ const key = `session:activity:${sessionId}`;
+
+ this.redis
+ .set(key, '1', 'EX', THROTTLE_SECONDS, 'NX')
+ .then((result) => {
+ if (result === null) return; // key already exists, throttled
+
+ this.userSessionRepo.updateLastActiveAt(sessionId).catch(() => {});
+ this.userRepo
+ .updateUser({ lastActiveAt: new Date() }, userId, workspaceId)
+ .catch(() => {});
+ })
+ .catch(() => {});
+ }
+}
diff --git a/apps/server/src/core/session/session.controller.ts b/apps/server/src/core/session/session.controller.ts
new file mode 100644
index 00000000..75b83c06
--- /dev/null
+++ b/apps/server/src/core/session/session.controller.ts
@@ -0,0 +1,80 @@
+import {
+ BadRequestException,
+ Body,
+ Controller,
+ HttpCode,
+ HttpStatus,
+ Post,
+ Req,
+ UseGuards,
+} from '@nestjs/common';
+import { SessionService } from './session.service';
+import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
+import { AuthUser } from '../../common/decorators/auth-user.decorator';
+import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
+import { User, Workspace } from '@docmost/db/types/entity.types';
+import { RevokeSessionDto } from './dto/revoke-session.dto';
+import { FastifyRequest } from 'fastify';
+
+@UseGuards(JwtAuthGuard)
+@Controller('sessions')
+export class SessionController {
+ constructor(private readonly sessionService: SessionService) {}
+
+ @HttpCode(HttpStatus.OK)
+ @Post()
+ async listSessions(
+ @AuthUser() user: User,
+ @AuthWorkspace() workspace: Workspace,
+ @Req() req: FastifyRequest,
+ ) {
+ const currentSessionId = (req.raw as any).sessionId ?? null;
+ const sessions = await this.sessionService.getActiveSessions(
+ user.id,
+ workspace.id,
+ currentSessionId,
+ );
+ return { sessions };
+ }
+
+ @HttpCode(HttpStatus.OK)
+ @Post('revoke')
+ async revokeSession(
+ @Body() dto: RevokeSessionDto,
+ @AuthUser() user: User,
+ @AuthWorkspace() workspace: Workspace,
+ @Req() req: FastifyRequest,
+ ) {
+ const currentSessionId = (req.raw as any).sessionId;
+ if (dto.sessionId === currentSessionId) {
+ throw new BadRequestException(
+ 'Cannot revoke current session. Use logout instead.',
+ );
+ }
+ await this.sessionService.revokeSession(
+ dto.sessionId,
+ user.id,
+ workspace.id,
+ );
+ }
+
+ @HttpCode(HttpStatus.OK)
+ @Post('revoke-all')
+ async revokeAllSessions(
+ @AuthUser() user: User,
+ @AuthWorkspace() workspace: Workspace,
+ @Req() req: FastifyRequest,
+ ) {
+ const currentSessionId = (req.raw as any).sessionId;
+ if (!currentSessionId) {
+ throw new BadRequestException(
+ 'Current session not found. Please log in again.',
+ );
+ }
+ await this.sessionService.revokeAllOtherSessions(
+ currentSessionId,
+ user.id,
+ workspace.id,
+ );
+ }
+}
diff --git a/apps/server/src/core/session/session.module.ts b/apps/server/src/core/session/session.module.ts
new file mode 100644
index 00000000..9712e887
--- /dev/null
+++ b/apps/server/src/core/session/session.module.ts
@@ -0,0 +1,14 @@
+import { Global, Module } from '@nestjs/common';
+import { SessionService } from './session.service';
+import { SessionActivityService } from './session-activity.service';
+import { SessionController } from './session.controller';
+import { TokenModule } from '../auth/token.module';
+
+@Global()
+@Module({
+ imports: [TokenModule],
+ controllers: [SessionController],
+ providers: [SessionService, SessionActivityService],
+ exports: [SessionService, SessionActivityService],
+})
+export class SessionModule {}
diff --git a/apps/server/src/core/session/session.service.ts b/apps/server/src/core/session/session.service.ts
new file mode 100644
index 00000000..fa3bbb5f
--- /dev/null
+++ b/apps/server/src/core/session/session.service.ts
@@ -0,0 +1,111 @@
+import { Injectable } from '@nestjs/common';
+import { TokenService } from '../auth/services/token.service';
+import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
+import { EnvironmentService } from '../../integrations/environment/environment.service';
+import { User } from '@docmost/db/types/entity.types';
+import { ClsService } from 'nestjs-cls';
+import {
+ AuditContext,
+ AUDIT_CONTEXT_KEY,
+} from '../../common/middlewares/audit-context.middleware';
+import * as Bowser from 'bowser';
+
+@Injectable()
+export class SessionService {
+ constructor(
+ private readonly tokenService: TokenService,
+ private readonly userSessionRepo: UserSessionRepo,
+ private readonly environmentService: EnvironmentService,
+ private readonly cls: ClsService,
+ ) {}
+
+ async createSessionAndToken(user: User): Promise {
+ const auditContext = this.cls.get(AUDIT_CONTEXT_KEY);
+ const ipAddress = auditContext?.ipAddress ?? null;
+ const userAgent = auditContext?.userAgent ?? null;
+
+ const deviceName = this.parseDeviceName(userAgent);
+ const expiresAt = this.environmentService.getCookieExpiresIn();
+
+ const session = await this.userSessionRepo.insertSession({
+ userId: user.id,
+ workspaceId: user.workspaceId,
+ deviceName,
+ ipAddress,
+ expiresAt,
+ });
+
+ return this.tokenService.generateAccessToken(user, session.id);
+ }
+
+ async getActiveSessions(
+ userId: string,
+ workspaceId: string,
+ currentSessionId: string | null,
+ ) {
+ const sessions = await this.userSessionRepo.findActiveByUser(
+ userId,
+ workspaceId,
+ );
+
+ const mapped = sessions.map((s) => ({
+ id: s.id,
+ deviceName: s.deviceName,
+ ipAddress: s.ipAddress,
+ geoLocation: s.geoLocation,
+ lastActiveAt: s.lastActiveAt,
+ createdAt: s.createdAt,
+ isCurrent: s.id === currentSessionId,
+ }));
+
+ return mapped.sort((a, b) => {
+ if (a.isCurrent) return -1;
+ if (b.isCurrent) return 1;
+ return 0;
+ });
+ }
+
+ async revokeSession(
+ sessionId: string,
+ userId: string,
+ workspaceId: string,
+ ): Promise {
+ await this.userSessionRepo.revokeById(sessionId, userId, workspaceId);
+ }
+
+ async revokeAllOtherSessions(
+ currentSessionId: string,
+ userId: string,
+ workspaceId: string,
+ ): Promise {
+ await this.userSessionRepo.revokeAllExceptCurrent(
+ currentSessionId,
+ userId,
+ workspaceId,
+ );
+ }
+
+ private parseDeviceName(userAgent: string | null): string | null {
+ if (!userAgent) return null;
+
+ try {
+ const parsed = Bowser.parse(userAgent);
+
+ const os = parsed.os?.name;
+ const browser = parsed.browser?.name;
+ const platformType = parsed.platform?.type;
+
+ if (platformType === 'mobile' || platformType === 'tablet') {
+ return parsed.platform?.model || os || 'Mobile Device';
+ }
+
+ if (os) {
+ return browser ? `${browser} on ${os}` : os;
+ }
+
+ return browser || null;
+ } catch {
+ return null;
+ }
+ }
+}
diff --git a/apps/server/src/core/workspace/services/workspace-invitation.service.ts b/apps/server/src/core/workspace/services/workspace-invitation.service.ts
index e6ebe7ff..50ed49f0 100644
--- a/apps/server/src/core/workspace/services/workspace-invitation.service.ts
+++ b/apps/server/src/core/workspace/services/workspace-invitation.service.ts
@@ -22,6 +22,7 @@ import InvitationEmail from '@docmost/transactional/emails/invitation-email';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import InvitationAcceptedEmail from '@docmost/transactional/emails/invitation-accepted-email';
import { TokenService } from '../../auth/services/token.service';
+import { SessionService } from '../../session/session.service';
import { nanoIdGen } from '../../../common/helpers';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
@@ -49,6 +50,7 @@ export class WorkspaceInvitationService {
private mailService: MailService,
private domainService: DomainService,
private tokenService: TokenService,
+ private sessionService: SessionService,
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
private readonly environmentService: EnvironmentService,
@@ -350,7 +352,7 @@ export class WorkspaceInvitationService {
};
}
- const authToken = await this.tokenService.generateAccessToken(newUser);
+ const authToken = await this.sessionService.createSessionAndToken(newUser);
return { authToken };
}
diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts
index a52506cd..fc187f3f 100644
--- a/apps/server/src/core/workspace/services/workspace.service.ts
+++ b/apps/server/src/core/workspace/services/workspace.service.ts
@@ -7,6 +7,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { LicenseCheckService } from '../../../integrations/environment/license-check.service';
+import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
import { CreateWorkspaceDto } from '../dto/create-workspace.dto';
import { UpdateWorkspaceDto } from '../dto/update-workspace.dto';
import { SpaceService } from '../../space/services/space.service';
@@ -67,6 +68,7 @@ export class WorkspaceService {
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
+ private userSessionRepo: UserSessionRepo,
) {}
async findById(workspaceId: string) {
@@ -673,6 +675,8 @@ export class WorkspaceService {
workspaceId,
);
+ await this.userSessionRepo.revokeByUserId(userId, workspaceId);
+
this.auditService.log({
event: AuditEvent.USER_DEACTIVATED,
resourceType: AuditResource.USER,
@@ -787,6 +791,8 @@ export class WorkspaceService {
});
});
+ await this.userSessionRepo.revokeByUserId(userId, workspaceId);
+
this.auditService.log({
event: AuditEvent.USER_DELETED,
resourceType: AuditResource.USER,
diff --git a/apps/server/src/database/database.module.ts b/apps/server/src/database/database.module.ts
index 3503e4ea..6c9a7e56 100644
--- a/apps/server/src/database/database.module.ts
+++ b/apps/server/src/database/database.module.ts
@@ -17,6 +17,7 @@ import { KyselyDB } from '@docmost/db/types/kysely.types';
import * as process from 'node:process';
import { MigrationService } from '@docmost/db/services/migration.service';
import { UserTokenRepo } from './repos/user-token/user-token.repo';
+import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
@@ -76,6 +77,7 @@ import { normalizePostgresUrl } from '../common/helpers';
CommentRepo,
AttachmentRepo,
UserTokenRepo,
+ UserSessionRepo,
BacklinkRepo,
ShareRepo,
NotificationRepo,
@@ -95,6 +97,7 @@ import { normalizePostgresUrl } from '../common/helpers';
CommentRepo,
AttachmentRepo,
UserTokenRepo,
+ UserSessionRepo,
BacklinkRepo,
ShareRepo,
NotificationRepo,
diff --git a/apps/server/src/database/migrations/20260325T120000-user-sessions.ts b/apps/server/src/database/migrations/20260325T120000-user-sessions.ts
new file mode 100644
index 00000000..480e4c90
--- /dev/null
+++ b/apps/server/src/database/migrations/20260325T120000-user-sessions.ts
@@ -0,0 +1,44 @@
+import { Kysely, sql } from 'kysely';
+
+export async function up(db: Kysely): Promise {
+ await db.schema
+ .createTable('user_sessions')
+ .addColumn('id', 'uuid', (col) =>
+ col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
+ )
+ .addColumn('user_id', 'uuid', (col) =>
+ col.notNull().references('users.id').onDelete('cascade'),
+ )
+ .addColumn('workspace_id', 'uuid', (col) =>
+ col.notNull().references('workspaces.id').onDelete('cascade'),
+ )
+ .addColumn('device_name', 'varchar')
+ .addColumn('user_agent', 'text')
+ .addColumn('ip_address', sql`inet`)
+ .addColumn('geo_location', 'varchar')
+ .addColumn('last_active_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .addColumn('expires_at', 'timestamptz', (col) => col.notNull())
+ .addColumn('revoked_at', 'timestamptz')
+ .addColumn('created_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .execute();
+
+ await db.schema
+ .createIndex('idx_user_sessions_user_workspace')
+ .on('user_sessions')
+ .columns(['user_id', 'workspace_id'])
+ .execute();
+
+ await db.schema
+ .createIndex('idx_user_sessions_expires_at')
+ .on('user_sessions')
+ .column('expires_at')
+ .execute();
+}
+
+export async function down(db: Kysely): Promise {
+ await db.schema.dropTable('user_sessions').execute();
+}
diff --git a/apps/server/src/database/repos/session/user-session.repo.ts b/apps/server/src/database/repos/session/user-session.repo.ts
new file mode 100644
index 00000000..4edc0743
--- /dev/null
+++ b/apps/server/src/database/repos/session/user-session.repo.ts
@@ -0,0 +1,125 @@
+import {
+ InsertableUserSession,
+ UserSession,
+} from '@docmost/db/types/entity.types';
+import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
+import { dbOrTx } from '@docmost/db/utils';
+import { Injectable } from '@nestjs/common';
+import { InjectKysely } from 'nestjs-kysely';
+
+@Injectable()
+export class UserSessionRepo {
+ constructor(@InjectKysely() private readonly db: KyselyDB) {}
+
+ async insertSession(
+ session: InsertableUserSession,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ return db
+ .insertInto('userSessions')
+ .values(session)
+ .returningAll()
+ .executeTakeFirstOrThrow();
+ }
+
+ async findActiveById(id: string): Promise {
+ return this.db
+ .selectFrom('userSessions')
+ .selectAll()
+ .where('id', '=', id)
+ .where('expiresAt', '>', new Date())
+ .where('revokedAt', 'is', null)
+ .executeTakeFirst();
+ }
+
+ async findActiveByUser(
+ userId: string,
+ workspaceId: string,
+ ): Promise {
+ return this.db
+ .selectFrom('userSessions')
+ .selectAll()
+ .where('userId', '=', userId)
+ .where('workspaceId', '=', workspaceId)
+ .where('expiresAt', '>', new Date())
+ .where('revokedAt', 'is', null)
+ .orderBy('lastActiveAt', 'desc')
+ .execute();
+ }
+
+ async updateLastActiveAt(id: string): Promise {
+ await this.db
+ .updateTable('userSessions')
+ .set({ lastActiveAt: new Date() })
+ .where('id', '=', id)
+ .execute();
+ }
+
+ async revokeById(
+ id: string,
+ userId: string,
+ workspaceId: string,
+ ): Promise {
+ await this.db
+ .updateTable('userSessions')
+ .set({ revokedAt: new Date() })
+ .where('id', '=', id)
+ .where('userId', '=', userId)
+ .where('workspaceId', '=', workspaceId)
+ .where('revokedAt', 'is', null)
+ .execute();
+ }
+
+ async revokeAllExceptCurrent(
+ currentSessionId: string,
+ userId: string,
+ workspaceId: string,
+ ): Promise {
+ await this.db
+ .updateTable('userSessions')
+ .set({ revokedAt: new Date() })
+ .where('userId', '=', userId)
+ .where('workspaceId', '=', workspaceId)
+ .where('id', '!=', currentSessionId)
+ .where('revokedAt', 'is', null)
+ .execute();
+ }
+
+ async revokeByUserId(
+ userId: string,
+ workspaceId: string,
+ ): Promise {
+ await this.db
+ .updateTable('userSessions')
+ .set({ revokedAt: new Date() })
+ .where('userId', '=', userId)
+ .where('workspaceId', '=', workspaceId)
+ .where('revokedAt', 'is', null)
+ .execute();
+ }
+
+ async deleteByUserId(
+ userId: string,
+ workspaceId: string,
+ ): Promise {
+ await this.db
+ .deleteFrom('userSessions')
+ .where('userId', '=', userId)
+ .where('workspaceId', '=', workspaceId)
+ .execute();
+ }
+
+ async deleteAllExceptCurrent(
+ currentSessionId: string,
+ userId: string,
+ workspaceId: string,
+ ): Promise {
+ await this.db
+ .deleteFrom('userSessions')
+ .where('userId', '=', userId)
+ .where('workspaceId', '=', workspaceId)
+ .where('id', '!=', currentSessionId)
+ .execute();
+ }
+}
diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts
index ed166b75..776e7248 100644
--- a/apps/server/src/database/types/db.d.ts
+++ b/apps/server/src/database/types/db.d.ts
@@ -429,6 +429,20 @@ export interface PagePermissions {
updatedAt: Generated;
}
+export interface UserSessions {
+ id: Generated;
+ userId: string;
+ workspaceId: string;
+ deviceName: string | null;
+ userAgent: string | null;
+ ipAddress: string | null;
+ geoLocation: string | null;
+ lastActiveAt: Generated;
+ expiresAt: Timestamp;
+ revokedAt: Timestamp | null;
+ createdAt: Generated;
+}
+
export interface DB {
apiKeys: ApiKeys;
attachments: Attachments;
@@ -451,6 +465,7 @@ export interface DB {
spaces: Spaces;
userMfa: UserMfa;
users: Users;
+ userSessions: UserSessions;
userTokens: UserTokens;
watchers: Watchers;
workspaceInvitations: WorkspaceInvitations;
diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts
index f8bf9ff7..d23e7475 100644
--- a/apps/server/src/database/types/entity.types.ts
+++ b/apps/server/src/database/types/entity.types.ts
@@ -22,6 +22,7 @@ import {
Shares,
FileTasks,
UserMfa as _UserMFA,
+ UserSessions,
ApiKeys,
Watchers,
Audit as _Audit,
@@ -157,6 +158,11 @@ export type PagePermission = Selectable<_PagePermissions>;
export type InsertablePagePermission = Insertable<_PagePermissions>;
export type UpdatablePagePermission = Updateable>;
+// User Session
+export type UserSession = Selectable;
+export type InsertableUserSession = Insertable;
+export type UpdatableUserSession = Updateable>;
+
// Audit
export type Audit = Selectable<_Audit>;
export type InsertableAudit = Insertable<_Audit>;
diff --git a/apps/server/src/ee b/apps/server/src/ee
index 73b69538..ebf89155 160000
--- a/apps/server/src/ee
+++ b/apps/server/src/ee
@@ -1 +1 @@
-Subproject commit 73b6953888a626ef8a6c02cdc372d52b4ea031c6
+Subproject commit ebf891554c031ad72d2b2bdbb09018436b3079c3
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b979f3c6..d3927e4f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -552,6 +552,9 @@ importers:
bcrypt:
specifier: ^6.0.0
version: 6.0.0
+ bowser:
+ specifier: ^2.14.1
+ version: 2.14.1
bullmq:
specifier: ^5.71.0
version: 5.71.0
@@ -5840,8 +5843,8 @@ packages:
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
- bowser@2.11.0:
- resolution: {integrity: sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==}
+ bowser@2.14.1:
+ resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
boxen@5.1.2:
resolution: {integrity: sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==}
@@ -11381,7 +11384,7 @@ snapshots:
dependencies:
'@aws-sdk/types': 3.973.6
'@smithy/types': 4.13.1
- bowser: 2.11.0
+ bowser: 2.14.1
tslib: 2.8.1
'@aws-sdk/util-user-agent-node@3.973.10':
@@ -16625,7 +16628,7 @@ snapshots:
boolbase@1.0.0: {}
- bowser@2.11.0: {}
+ bowser@2.14.1: {}
boxen@5.1.2:
dependencies: