Files
antrean-operasi/server/utils/userSync.ts
2026-02-02 08:13:15 +07:00

335 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// server/utils/userSync.ts
// Shared utility for syncing user data from JWT tokens to database
import Database from 'better-sqlite3';
import { join } from 'path';
import { existsSync, mkdirSync } from 'fs';
// Helper to get database path
const getDbPath = () => {
const dbDir = join(process.cwd(), 'data');
if (!existsSync(dbDir)) {
mkdirSync(dbDir, { recursive: true });
}
return join(dbDir, 'users.db');
};
// Initialize database and create table if not exists
const initDb = () => {
const dbPath = getDbPath();
const db = new Database(dbPath);
// Create table if not exists
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
namaLengkap TEXT NOT NULL,
namaUser TEXT UNIQUE NOT NULL,
email TEXT,
tipeUser TEXT DEFAULT '',
lastLogin INTEGER,
roles TEXT DEFAULT '[]',
realmRoles TEXT DEFAULT '[]',
accountRoles TEXT DEFAULT '[]',
resourceRoles TEXT DEFAULT '[]',
groups TEXT DEFAULT '[]',
given_name TEXT,
family_name TEXT,
createdAt INTEGER DEFAULT (strftime('%s', 'now')),
updatedAt INTEGER DEFAULT (strftime('%s', 'now'))
)
`);
// Migration: Check and add missing columns one by one
try {
const tableInfo = db.prepare("PRAGMA table_info(users)").all() as any[];
const columnNames = tableInfo.map(col => col.name);
// Add missing columns one by one
if (!columnNames.includes('realmRoles')) {
db.exec(`ALTER TABLE users ADD COLUMN realmRoles TEXT DEFAULT '[]'`);
console.log('✅ Added column: realmRoles');
}
if (!columnNames.includes('accountRoles')) {
db.exec(`ALTER TABLE users ADD COLUMN accountRoles TEXT DEFAULT '[]'`);
console.log('✅ Added column: accountRoles');
}
if (!columnNames.includes('resourceRoles')) {
db.exec(`ALTER TABLE users ADD COLUMN resourceRoles TEXT DEFAULT '[]'`);
console.log('✅ Added column: resourceRoles');
}
if (!columnNames.includes('lastLogin')) {
db.exec(`ALTER TABLE users ADD COLUMN lastLogin INTEGER`);
console.log('✅ Added column: lastLogin');
}
if (!columnNames.includes('given_name')) {
db.exec(`ALTER TABLE users ADD COLUMN given_name TEXT`);
console.log('✅ Added column: given_name');
}
if (!columnNames.includes('family_name')) {
db.exec(`ALTER TABLE users ADD COLUMN family_name TEXT`);
console.log('✅ Added column: family_name');
}
} catch (e: any) {
console.error('❌ Migration error:', e.message);
// Don't throw, continue with existing schema
}
return db;
};
// Helper function to decode JWT token payload
const decodeTokenPayload = (token: string | undefined): any | null => {
if (!token) return null;
try {
const parts = token.split(".");
if (parts.length < 2) return null;
const payloadBase64 = parts[1];
return JSON.parse(Buffer.from(payloadBase64, "base64").toString());
} catch (e) {
console.error("❌ Failed to decode token payload:", e);
return null;
}
};
/**
* Sync user data from JWT tokens to database
* @param idToken - The ID token from Keycloak
* @param accessToken - The access token from Keycloak
* @param loginTime - Optional login timestamp (from session createdAt). If not provided, uses current time
* @returns Object with success status and action taken
*/
export const syncUserFromTokens = (
idToken: string,
accessToken: string,
loginTime?: number
): { success: boolean; action: 'created' | 'updated' | 'unchanged'; message: string } => {
try {
// Decode tokens
const idTokenPayload = decodeTokenPayload(idToken);
const accessTokenPayload = decodeTokenPayload(accessToken);
if (!idTokenPayload) {
throw new Error('Invalid ID token');
}
// Extract user data
const userId = idTokenPayload.sub;
const namaLengkap = idTokenPayload.name ||
`${idTokenPayload.given_name || ''} ${idTokenPayload.family_name || ''}`.trim() ||
idTokenPayload.preferred_username;
const namaUser = idTokenPayload.preferred_username ||
idTokenPayload.email?.split('@')[0];
const email = idTokenPayload.email;
if (!userId || !namaUser) {
throw new Error('Missing required user data (id or username)');
}
// Extract roles from different sources
const realmRoles = accessTokenPayload?.realm_access?.roles ||
idTokenPayload?.realm_access?.roles ||
[];
const accountRoles = accessTokenPayload?.resource_access?.account?.roles ||
idTokenPayload?.resource_access?.account?.roles ||
[];
// Extract resource roles (from all resources in resource_access)
const resourceRoles: string[] = [];
if (accessTokenPayload?.resource_access) {
Object.keys(accessTokenPayload.resource_access).forEach(resourceName => {
if (resourceName !== 'account') { // Exclude account, already handled
const resourceRolesArray = accessTokenPayload.resource_access[resourceName]?.roles || [];
resourceRoles.push(...resourceRolesArray.map((role: string) => `${resourceName}:${role}`));
}
});
}
if (idTokenPayload?.resource_access) {
Object.keys(idTokenPayload.resource_access).forEach(resourceName => {
if (resourceName !== 'account') { // Exclude account, already handled
const resourceRolesArray = idTokenPayload.resource_access[resourceName]?.roles || [];
resourceRoles.push(...resourceRolesArray.map((role: string) => `${resourceName}:${role}`));
}
});
}
// Legacy: Combined roles (for backward compatibility)
const roles = [...realmRoles, ...accountRoles, ...resourceRoles];
// Keycloak uses 'groups_join' in access token, not 'groups'
const groups = accessTokenPayload?.groups_join ||
accessTokenPayload?.groups ||
idTokenPayload?.groups_join ||
idTokenPayload?.groups ||
[];
// Determine tipeUser from groups or roles if possible
// Extract from groups path (e.g., "/Instalasi STIM/Devops/Superadmin" -> "Superadmin")
let tipeUser = '';
if (Array.isArray(groups) && groups.length > 0) {
// Get the last group path and extract the last segment
const lastGroup = groups[groups.length - 1];
if (typeof lastGroup === 'string') {
const parts = lastGroup.split('/').filter(Boolean);
if (parts.length > 0) {
tipeUser = parts[parts.length - 1]; // Get last part of path
}
}
}
// If no tipeUser from groups, you can also check roles or leave empty for manual entry
// Use provided loginTime or current time (convert to seconds if in milliseconds)
const lastLoginTimestamp = loginTime
? (loginTime > 10000000000 ? Math.floor(loginTime / 1000) : loginTime) // Convert ms to seconds if needed
: Math.floor(Date.now() / 1000);
// Initialize database
const db = initDb();
// Check if user exists
const existingUser = db.prepare('SELECT * FROM users WHERE id = ?').get(userId) as any;
const rolesJson = JSON.stringify(Array.isArray(roles) ? roles : []);
const realmRolesJson = JSON.stringify(Array.isArray(realmRoles) ? realmRoles : []);
const accountRolesJson = JSON.stringify(Array.isArray(accountRoles) ? accountRoles : []);
const resourceRolesJson = JSON.stringify(Array.isArray(resourceRoles) ? resourceRoles : []);
const groupsJson = JSON.stringify(Array.isArray(groups) ? groups : []);
if (existingUser) {
// User exists - check if data needs updating
// Note: tipeUser is only updated if it's empty in database (to preserve manual edits)
const needsUpdate =
existingUser.namaLengkap !== namaLengkap ||
existingUser.namaUser !== namaUser ||
existingUser.email !== email ||
existingUser.roles !== rolesJson ||
existingUser.realmRoles !== realmRolesJson ||
existingUser.accountRoles !== accountRolesJson ||
existingUser.resourceRoles !== resourceRolesJson ||
existingUser.groups !== groupsJson ||
existingUser.given_name !== (idTokenPayload.given_name || null) ||
existingUser.family_name !== (idTokenPayload.family_name || null) ||
(existingUser.tipeUser === '' && tipeUser !== ''); // Only update if empty
// Always update lastLogin when loginTime is provided (user is logging in)
// Check if lastLogin needs updating (if loginTime provided or new timestamp is newer)
const existingLastLogin = existingUser.lastLogin || 0;
const needsLastLoginUpdate = loginTime !== undefined
? true // Always update on login when loginTime is provided
: (lastLoginTimestamp > existingLastLogin); // Only update if newer when not a login event
// Update if any data changed OR if lastLogin needs updating
if (needsUpdate || needsLastLoginUpdate) {
// Update user data
// Only update tipeUser if it's currently empty (preserve manual edits)
const updateTipeUser = existingUser.tipeUser === '' ? tipeUser : existingUser.tipeUser;
db.prepare(`
UPDATE users
SET namaLengkap = ?,
namaUser = ?,
email = ?,
roles = ?,
realmRoles = ?,
accountRoles = ?,
resourceRoles = ?,
groups = ?,
given_name = ?,
family_name = ?,
tipeUser = ?,
lastLogin = ?,
updatedAt = strftime('%s', 'now')
WHERE id = ?
`).run(
namaLengkap,
namaUser,
email || null,
rolesJson,
realmRolesJson,
accountRolesJson,
resourceRolesJson,
groupsJson,
idTokenPayload.given_name || null,
idTokenPayload.family_name || null,
updateTipeUser,
lastLoginTimestamp, // Update lastLogin timestamp from session
userId
);
if (needsLastLoginUpdate && !needsUpdate) {
console.log("✅ User lastLogin updated:", userId, "new timestamp:", lastLoginTimestamp);
} else {
console.log("✅ User data updated:", userId);
}
db.close();
return {
success: true,
action: 'updated',
message: 'User data updated successfully'
};
} else {
console.log(" User data unchanged:", userId);
db.close();
return {
success: true,
action: 'unchanged',
message: 'User data is up to date'
};
}
} else {
// New user - insert
console.log(" Inserting new user:", {
userId,
namaLengkap,
namaUser,
email,
lastLoginTimestamp
});
db.prepare(`
INSERT INTO users (
id, namaLengkap, namaUser, email, roles, realmRoles, accountRoles, resourceRoles, groups,
given_name, family_name, tipeUser, lastLogin
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
userId,
namaLengkap,
namaUser,
email || null,
rolesJson,
realmRolesJson,
accountRolesJson,
resourceRolesJson,
groupsJson,
idTokenPayload.given_name || null,
idTokenPayload.family_name || null,
tipeUser, // tipeUser - extracted from groups or empty
lastLoginTimestamp // lastLogin - from session createdAt
);
console.log("✅ New user saved to database:", {
userId,
namaUser,
namaLengkap,
lastLogin: lastLoginTimestamp ? new Date(lastLoginTimestamp * 1000).toISOString() : 'null'
});
db.close();
return {
success: true,
action: 'created',
message: `New user ${namaUser} saved successfully`
};
}
} catch (error: any) {
console.error("❌ Error syncing user:", error);
throw error;
}
};