feat(reception): Add activity logging, session metadata and org-selection UI (backend and frontend)

This commit is contained in:
2025-12-01 18:56:16 +00:00
parent d11f5a0c72
commit 8756258324
20 changed files with 2512 additions and 192 deletions
+62
View File
@@ -0,0 +1,62 @@
import * as plugins from '../plugins.js';
import { ActivityLogManager } from './classes.activitylogmanager.js';
/**
* ActivityLog tracks user actions for audit and display purposes
*/
@plugins.smartdata.Manager()
export class ActivityLog extends plugins.smartdata.SmartDataDbDoc<
ActivityLog,
plugins.idpInterfaces.data.IActivityLog,
ActivityLogManager
> {
// ======
// static
// ======
public static async createActivityLog(
managerArg: ActivityLogManager,
userId: string,
action: plugins.idpInterfaces.data.TActivityAction,
description: string,
metadata?: {
ip?: string;
userAgent?: string;
targetId?: string;
targetType?: string;
}
) {
const activityLog = new managerArg.CActivityLog();
activityLog.id = plugins.smartunique.shortId();
activityLog.data = {
userId,
action,
timestamp: Date.now(),
metadata: {
description,
...metadata,
},
};
await activityLog.save();
return activityLog;
}
// ========
// INSTANCE
// ========
@plugins.smartdata.unI()
public id: string;
@plugins.smartdata.svDb()
public data: plugins.idpInterfaces.data.IActivityLog['data'] = {
userId: null,
action: null,
timestamp: null,
metadata: {
description: null,
},
};
constructor() {
super();
}
}
@@ -0,0 +1,77 @@
import * as plugins from '../plugins.js';
import { ActivityLog } from './classes.activitylog.js';
import { Reception } from './classes.reception.js';
export class ActivityLogManager {
// refs
public receptionRef: Reception;
public get db() {
return this.receptionRef.db.smartdataDb;
}
public CActivityLog = plugins.smartdata.setDefaultManagerForDoc(this, ActivityLog);
public typedRouter = new plugins.typedrequest.TypedRouter();
constructor(receptionRefArg: Reception) {
this.receptionRef = receptionRefArg;
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
// Get user activity handler
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetUserActivity>(
'getUserActivity',
async (requestArg) => {
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
if (!jwt) {
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
}
const limit = requestArg.limit || 20;
const offset = requestArg.offset || 0;
// Get activities for this user
const activities = await this.CActivityLog.getInstances({
'data.userId': jwt.data.userId,
});
// Sort by timestamp descending
const sortedActivities = activities
.sort((a, b) => b.data.timestamp - a.data.timestamp)
.slice(offset, offset + limit);
return {
activities: sortedActivities.map((a) => ({
id: a.id,
data: a.data,
})),
total: activities.length,
};
}
)
);
}
/**
* Log a user activity
*/
public async logActivity(
userId: string,
action: plugins.idpInterfaces.data.TActivityAction,
description: string,
metadata?: {
ip?: string;
userAgent?: string;
targetId?: string;
targetType?: string;
}
) {
return await ActivityLog.createActivityLog(
this,
userId,
action,
description,
metadata
);
}
}
+4 -1
View File
@@ -60,7 +60,10 @@ export class LoginSession extends plugins.smartdata.SmartDataDbDoc<
validUntil: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ weeks: 1 }),
invalidated: false,
refreshToken: null,
deviceId: null
deviceId: null,
deviceInfo: null,
createdAt: Date.now(),
lastActive: Date.now(),
};
public transferToken: string;
+78 -1
View File
@@ -259,6 +259,83 @@ export class LoginSessionManager {
ok: false
}
})
)
);
// Get all sessions for the current user
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetUserSessions>(
'getUserSessions',
async (requestArg) => {
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
if (!jwt) {
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
}
// Get the current session's refresh token to identify the current session
const currentRefreshToken = jwt.data.refreshToken;
// Get all sessions for this user
const sessions = await this.CLoginSession.getInstances({
'data.userId': jwt.data.userId,
'data.invalidated': false,
});
return {
sessions: sessions.map((session) => ({
id: session.id,
deviceId: session.data.deviceId || 'unknown',
deviceName: session.data.deviceInfo?.deviceName || 'Unknown Device',
browser: session.data.deviceInfo?.browser || 'Unknown Browser',
os: session.data.deviceInfo?.os || 'Unknown OS',
ip: session.data.deviceInfo?.ip || 'Unknown',
lastActive: session.data.lastActive || session.data.createdAt || Date.now(),
createdAt: session.data.createdAt || Date.now(),
isCurrent: session.data.refreshToken === currentRefreshToken,
})),
};
}
)
);
// Revoke a specific session
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_RevokeSession>(
'revokeSession',
async (requestArg) => {
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
if (!jwt) {
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
}
// Get the session to revoke
const sessionToRevoke = await this.CLoginSession.getInstance({
id: requestArg.sessionId,
'data.userId': jwt.data.userId, // Ensure user can only revoke their own sessions
});
if (!sessionToRevoke) {
throw new plugins.typedrequest.TypedResponseError('Session not found');
}
// Don't allow revoking the current session via this method
if (sessionToRevoke.data.refreshToken === jwt.data.refreshToken) {
throw new plugins.typedrequest.TypedResponseError(
'Cannot revoke current session. Use logout instead.'
);
}
await sessionToRevoke.invalidate();
// Log the activity
await this.receptionRef.activityLogManager.logActivity(
jwt.data.userId,
'session_revoked',
`Revoked session on ${sessionToRevoke.data.deviceInfo?.deviceName || 'unknown device'}`
);
return { success: true };
}
)
);
}
}
+2
View File
@@ -15,6 +15,7 @@ import { RoleManager } from './classes.rolemanager.js';
import { BillingPlanManager } from './classes.billingplanmanager.js';
import { AppManager } from './classes.appmanager.js';
import { AppConnectionManager } from './classes.appconnectionmanager.js';
import { ActivityLogManager } from './classes.activitylogmanager.js';
export interface IReceptionOptions {
/**
@@ -45,6 +46,7 @@ export class Reception {
public billingPlanManager = new BillingPlanManager(this);
public appManager = new AppManager(this);
public appConnectionManager = new AppConnectionManager(this);
public activityLogManager = new ActivityLogManager(this);
housekeeping = new ReceptionHousekeeping(this);
constructor(public options: IReceptionOptions) {