358 lines
11 KiB
TypeScript
358 lines
11 KiB
TypeScript
import { User as UserModel } from '../models/User';
|
|
import { Op } from 'sequelize';
|
|
import { SSOUserData } from '../types/auth.types'; // Use shared type
|
|
import axios from 'axios';
|
|
|
|
// Using UserModel type directly - interface removed to avoid duplication
|
|
|
|
interface OktaUser {
|
|
id: string;
|
|
status: string;
|
|
profile: {
|
|
firstName?: string;
|
|
lastName?: string;
|
|
displayName?: string;
|
|
email: string;
|
|
login: string;
|
|
department?: string;
|
|
mobilePhone?: string;
|
|
};
|
|
}
|
|
|
|
export class UserService {
|
|
async createOrUpdateUser(ssoData: SSOUserData): Promise<UserModel> {
|
|
// Validate required fields
|
|
if (!ssoData.email || !ssoData.oktaSub) {
|
|
throw new Error('Email and Okta sub are required');
|
|
}
|
|
|
|
// Check if user exists by email (primary identifier) or oktaSub
|
|
const existingUser = await UserModel.findOne({
|
|
where: {
|
|
[Op.or]: [
|
|
{ email: ssoData.email },
|
|
{ oktaSub: ssoData.oktaSub }
|
|
]
|
|
}
|
|
});
|
|
|
|
const now = new Date();
|
|
|
|
if (existingUser) {
|
|
// Update existing user - include oktaSub to ensure it's synced
|
|
await existingUser.update({
|
|
email: ssoData.email,
|
|
oktaSub: ssoData.oktaSub,
|
|
employeeId: ssoData.employeeId || null, // Optional
|
|
firstName: ssoData.firstName || null,
|
|
lastName: ssoData.lastName || null,
|
|
displayName: ssoData.displayName || null,
|
|
department: ssoData.department || null,
|
|
designation: ssoData.designation || null,
|
|
phone: ssoData.phone || null,
|
|
// location: (ssoData as any).location || null, // Ignored for now - schema not finalized
|
|
lastLogin: now,
|
|
updatedAt: now,
|
|
isActive: true, // Ensure user is active after SSO login
|
|
});
|
|
|
|
return existingUser;
|
|
} else {
|
|
// Create new user - oktaSub is required
|
|
const newUser = await UserModel.create({
|
|
email: ssoData.email,
|
|
oktaSub: ssoData.oktaSub, // Required
|
|
employeeId: ssoData.employeeId || null, // Optional
|
|
firstName: ssoData.firstName || null,
|
|
lastName: ssoData.lastName || null,
|
|
displayName: ssoData.displayName || null,
|
|
department: ssoData.department || null,
|
|
designation: ssoData.designation || null,
|
|
phone: ssoData.phone || null,
|
|
// location: (ssoData as any).location || null, // Ignored for now - schema not finalized
|
|
isActive: true,
|
|
role: 'USER', // Default role for new users
|
|
lastLogin: now
|
|
});
|
|
|
|
return newUser;
|
|
}
|
|
}
|
|
|
|
async getUserById(userId: string): Promise<UserModel | null> {
|
|
return await UserModel.findByPk(userId);
|
|
}
|
|
|
|
async getUserByEmployeeId(employeeId: string): Promise<UserModel | null> {
|
|
return await UserModel.findOne({ where: { employeeId } });
|
|
}
|
|
|
|
async getAllUsers(): Promise<UserModel[]> {
|
|
return await UserModel.findAll({
|
|
order: [['createdAt', 'DESC']]
|
|
});
|
|
}
|
|
|
|
async searchUsers(query: string, limit: number = 10, excludeUserId?: string): Promise<any[]> {
|
|
const q = (query || '').trim();
|
|
if (!q) {
|
|
return [];
|
|
}
|
|
|
|
// Get the current user's email to exclude them from results
|
|
let excludeEmail: string | undefined;
|
|
if (excludeUserId) {
|
|
try {
|
|
const currentUser = await UserModel.findByPk(excludeUserId);
|
|
if (currentUser) {
|
|
excludeEmail = (currentUser as any).email?.toLowerCase();
|
|
}
|
|
} catch (err) {
|
|
// Ignore error - filtering will still work by userId for local search
|
|
}
|
|
}
|
|
|
|
// Search Okta users
|
|
try {
|
|
const oktaDomain = process.env.OKTA_DOMAIN;
|
|
const oktaApiToken = process.env.OKTA_API_TOKEN;
|
|
|
|
if (!oktaDomain || !oktaApiToken) {
|
|
return await this.searchUsersLocal(q, limit, excludeUserId);
|
|
}
|
|
|
|
const response = await axios.get(`${oktaDomain}/api/v1/users`, {
|
|
params: { q, limit: Math.min(limit, 50) },
|
|
headers: {
|
|
'Authorization': `SSWS ${oktaApiToken}`,
|
|
'Accept': 'application/json'
|
|
},
|
|
timeout: 5000
|
|
});
|
|
|
|
const oktaUsers: OktaUser[] = response.data || [];
|
|
|
|
// Transform Okta users to our format
|
|
return oktaUsers
|
|
.filter(u => {
|
|
// Filter out inactive users
|
|
if (u.status !== 'ACTIVE') return false;
|
|
|
|
// Filter out current user by Okta ID or email
|
|
if (excludeUserId && u.id === excludeUserId) return false;
|
|
if (excludeEmail) {
|
|
const userEmail = (u.profile.email || u.profile.login || '').toLowerCase();
|
|
if (userEmail === excludeEmail) return false;
|
|
}
|
|
|
|
return true;
|
|
})
|
|
.map(u => ({
|
|
userId: u.id,
|
|
oktaSub: u.id,
|
|
email: u.profile.email || u.profile.login,
|
|
displayName: u.profile.displayName || `${u.profile.firstName || ''} ${u.profile.lastName || ''}`.trim(),
|
|
firstName: u.profile.firstName,
|
|
lastName: u.profile.lastName,
|
|
department: u.profile.department,
|
|
phone: u.profile.mobilePhone,
|
|
isActive: true
|
|
}));
|
|
} catch (error: any) {
|
|
return await this.searchUsersLocal(q, limit, excludeUserId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fallback: Search users in local database
|
|
*/
|
|
private async searchUsersLocal(query: string, limit: number = 10, excludeUserId?: string): Promise<UserModel[]> {
|
|
const q = (query || '').trim();
|
|
if (!q) {
|
|
return [];
|
|
}
|
|
const like = `%${q}%`;
|
|
const orConds = [
|
|
{ email: { [Op.iLike as any]: like } as any },
|
|
{ displayName: { [Op.iLike as any]: like } as any },
|
|
{ firstName: { [Op.iLike as any]: like } as any },
|
|
{ lastName: { [Op.iLike as any]: like } as any },
|
|
];
|
|
const where: any = { [Op.or]: orConds };
|
|
if (excludeUserId) {
|
|
where.userId = { [Op.ne]: excludeUserId } as any;
|
|
}
|
|
return await UserModel.findAll({
|
|
where,
|
|
order: [['displayName', 'ASC']],
|
|
limit: Math.min(Math.max(limit || 10, 1), 50),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Fetch a user directly from Okta by their Okta ID
|
|
* Used when we have an Okta ID and need to get user details
|
|
*/
|
|
async fetchUserFromOktaById(oktaId: string): Promise<OktaUser | null> {
|
|
try {
|
|
const oktaDomain = process.env.OKTA_DOMAIN;
|
|
const oktaApiToken = process.env.OKTA_API_TOKEN;
|
|
|
|
if (!oktaDomain || !oktaApiToken) {
|
|
return null;
|
|
}
|
|
|
|
const response = await axios.get(`${oktaDomain}/api/v1/users/${oktaId}`, {
|
|
headers: {
|
|
'Authorization': `SSWS ${oktaApiToken}`,
|
|
'Accept': 'application/json'
|
|
},
|
|
timeout: 5000
|
|
});
|
|
|
|
return response.data as OktaUser;
|
|
} catch (error: any) {
|
|
if (error.response?.status === 404) {
|
|
// User not found in Okta
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch user from Okta by email
|
|
*/
|
|
async fetchUserFromOktaByEmail(email: string): Promise<OktaUser | null> {
|
|
try {
|
|
const oktaDomain = process.env.OKTA_DOMAIN;
|
|
const oktaApiToken = process.env.OKTA_API_TOKEN;
|
|
|
|
if (!oktaDomain || !oktaApiToken) {
|
|
return null;
|
|
}
|
|
|
|
// Search Okta users by email (exact match)
|
|
const response = await axios.get(`${oktaDomain}/api/v1/users`, {
|
|
params: { search: `profile.email eq "${email}"`, limit: 1 },
|
|
headers: {
|
|
'Authorization': `SSWS ${oktaApiToken}`,
|
|
'Accept': 'application/json'
|
|
},
|
|
timeout: 5000
|
|
});
|
|
|
|
const users: OktaUser[] = response.data || [];
|
|
return users.length > 0 ? users[0] : null;
|
|
} catch (error: any) {
|
|
console.error(`Failed to fetch user from Okta by email ${email}:`, error.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensure user exists in database (create if not exists)
|
|
* Used when tagging users from Okta search results or when only email is provided
|
|
*
|
|
* @param oktaUserData - Can be just { email } or full user data
|
|
*/
|
|
async ensureUserExists(oktaUserData: {
|
|
userId?: string;
|
|
email: string;
|
|
displayName?: string;
|
|
firstName?: string;
|
|
lastName?: string;
|
|
department?: string;
|
|
phone?: string;
|
|
designation?: string;
|
|
jobTitle?: string;
|
|
manager?: string;
|
|
employeeId?: string;
|
|
employeeNumber?: string;
|
|
secondEmail?: string;
|
|
mobilePhone?: string;
|
|
location?: string;
|
|
}): Promise<UserModel> {
|
|
const email = oktaUserData.email.toLowerCase();
|
|
|
|
// Check if user already exists in database
|
|
let user = await UserModel.findOne({
|
|
where: {
|
|
[Op.or]: [
|
|
{ email },
|
|
...(oktaUserData.userId ? [{ oktaSub: oktaUserData.userId }] : [])
|
|
]
|
|
}
|
|
});
|
|
|
|
if (user) {
|
|
// Update existing user with latest info from Okta (if provided)
|
|
const updateData: any = {
|
|
email,
|
|
isActive: true,
|
|
updatedAt: new Date()
|
|
};
|
|
|
|
if (oktaUserData.userId) updateData.oktaSub = oktaUserData.userId;
|
|
if (oktaUserData.firstName) updateData.firstName = oktaUserData.firstName;
|
|
if (oktaUserData.lastName) updateData.lastName = oktaUserData.lastName;
|
|
if (oktaUserData.displayName) updateData.displayName = oktaUserData.displayName;
|
|
if (oktaUserData.department) updateData.department = oktaUserData.department;
|
|
if (oktaUserData.phone) updateData.phone = oktaUserData.phone;
|
|
if (oktaUserData.designation) updateData.designation = oktaUserData.designation;
|
|
if (oktaUserData.employeeId) updateData.employeeId = oktaUserData.employeeId;
|
|
|
|
await user.update(updateData);
|
|
return user;
|
|
}
|
|
|
|
// User not found in DB - try to fetch from Okta
|
|
if (!oktaUserData.userId) {
|
|
const oktaUser = await this.fetchUserFromOktaByEmail(email);
|
|
if (oktaUser) {
|
|
// Found in Okta - create with Okta data
|
|
user = await UserModel.create({
|
|
oktaSub: oktaUser.id,
|
|
email,
|
|
employeeId: null,
|
|
firstName: oktaUser.profile.firstName || null,
|
|
lastName: oktaUser.profile.lastName || null,
|
|
displayName: oktaUser.profile.displayName || `${oktaUser.profile.firstName || ''} ${oktaUser.profile.lastName || ''}`.trim() || email.split('@')[0],
|
|
department: oktaUser.profile.department || null,
|
|
designation: null,
|
|
phone: oktaUser.profile.mobilePhone || null,
|
|
isActive: oktaUser.status === 'ACTIVE',
|
|
role: 'USER',
|
|
lastLogin: undefined,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date()
|
|
});
|
|
return user;
|
|
} else {
|
|
// Not found in Okta either
|
|
throw new Error(`User with email '${email}' not found in organization directory`);
|
|
}
|
|
}
|
|
|
|
// Create new user with provided data
|
|
user = await UserModel.create({
|
|
oktaSub: oktaUserData.userId,
|
|
email,
|
|
employeeId: oktaUserData.employeeId || null,
|
|
firstName: oktaUserData.firstName || null,
|
|
lastName: oktaUserData.lastName || null,
|
|
displayName: oktaUserData.displayName || email.split('@')[0],
|
|
department: oktaUserData.department || null,
|
|
designation: oktaUserData.designation || oktaUserData.jobTitle || null,
|
|
phone: oktaUserData.phone || oktaUserData.mobilePhone || null,
|
|
isActive: true,
|
|
role: 'USER',
|
|
lastLogin: undefined,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date()
|
|
});
|
|
|
|
return user;
|
|
}
|
|
}
|