Files
web-antrean/server/utils/userSync.ts
T
2025-12-16 10:42:45 +07:00

300 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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);
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: Add new columns if they don't exist (for existing databases)
try {
db.exec(`
ALTER TABLE users ADD COLUMN realmRoles TEXT DEFAULT '[]';
ALTER TABLE users ADD COLUMN accountRoles TEXT DEFAULT '[]';
ALTER TABLE users ADD COLUMN resourceRoles TEXT DEFAULT '[]';
ALTER TABLE users ADD COLUMN lastLogin INTEGER;
`);
} catch (e: any) {
// Columns might already exist, ignore error
if (!e.message?.includes('duplicate column')) {
console.warn('Migration note:', e.message);
}
}
// Migration: Rename keterangan to lastLogin if exists
try {
// Check if keterangan column exists
const tableInfo = db.prepare("PRAGMA table_info(users)").all() as any[];
const hasKeterangan = tableInfo.some(col => col.name === 'keterangan');
const hasLastLogin = tableInfo.some(col => col.name === 'lastLogin');
if (hasKeterangan && !hasLastLogin) {
// SQLite doesn't support ALTER COLUMN, so we need to recreate the table
// For now, we'll just add lastLogin and leave keterangan (it will be ignored)
console.log('Migration: Adding lastLogin column');
}
} catch (e: any) {
console.warn('Migration check note:', e.message);
}
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
if (needsUpdate) {
// 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
);
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
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:", userId);
db.close();
return {
success: true,
action: 'created',
message: 'New user saved successfully'
};
}
} catch (error: any) {
console.error("❌ Error syncing user:", error);
throw error;
}
};