feat(reception): Add activity logging, session metadata and org-selection UI (backend and frontend)
This commit is contained in:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user