Files
helix-engage-server/src/auth/auth.controller.ts
saridsa2 01348123e6 fix: map HelixEngage Supervisor platform role to admin app role
Supervisor users were getting 'executive' role because only 'HelixEngage
Manager' was mapped to admin. This broke admin route access after the
RequireAdmin guard was added.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 06:47:01 +05:30

290 lines
12 KiB
TypeScript

import { Controller, Post, Body, Headers, Req, Logger, HttpException } from '@nestjs/common';
import type { Request } from 'express';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
import { SessionService } from './session.service';
import { AgentConfigService } from './agent-config.service';
import { TelephonyConfigService } from '../config/telephony-config.service';
@Controller('auth')
export class AuthController {
private readonly logger = new Logger(AuthController.name);
private readonly graphqlUrl: string;
private readonly workspaceSubdomain: string;
private readonly origin: string;
constructor(
private config: ConfigService,
private ozonetelAgent: OzonetelAgentService,
private sessionService: SessionService,
private agentConfigService: AgentConfigService,
private telephony: TelephonyConfigService,
) {
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
this.workspaceSubdomain = process.env.PLATFORM_WORKSPACE_SUBDOMAIN ?? 'fortytwo-dev';
this.origin = process.env.PLATFORM_ORIGIN ?? 'http://fortytwo-dev.localhost:4010';
}
@Post('login')
async login(@Body() body: { email: string; password: string }, @Req() req: Request) {
this.logger.log(`Login attempt for ${body.email}`);
try {
// Step 1: Get login token
const loginRes = await axios.post(this.graphqlUrl, {
query: `mutation GetLoginToken($email: String!, $password: String!) {
getLoginTokenFromCredentials(
email: $email
password: $password
origin: "${this.origin}"
) {
loginToken { token }
}
}`,
variables: { email: body.email, password: body.password },
}, {
headers: {
'Content-Type': 'application/json',
'X-Workspace-Subdomain': this.workspaceSubdomain,
},
});
if (loginRes.data.errors) {
throw new HttpException(
loginRes.data.errors[0]?.message ?? 'Login failed',
401,
);
}
const loginToken = loginRes.data.data.getLoginTokenFromCredentials.loginToken.token;
// Step 2: Exchange for access + refresh tokens
const tokenRes = await axios.post(this.graphqlUrl, {
query: `mutation GetAuthTokens($loginToken: String!) {
getAuthTokensFromLoginToken(
loginToken: $loginToken
origin: "${this.origin}"
) {
tokens {
accessOrWorkspaceAgnosticToken { token }
refreshToken { token }
}
}
}`,
variables: { loginToken },
}, {
headers: {
'Content-Type': 'application/json',
'X-Workspace-Subdomain': this.workspaceSubdomain,
},
});
if (tokenRes.data.errors) {
throw new HttpException(
tokenRes.data.errors[0]?.message ?? 'Token exchange failed',
401,
);
}
const tokens = tokenRes.data.data.getAuthTokensFromLoginToken.tokens;
const accessToken = tokens.accessOrWorkspaceAgnosticToken.token;
// Step 3: Fetch user profile with roles
const profileRes = await axios.post(this.graphqlUrl, {
query: `{ currentUser { id email workspaceMember { id name { firstName lastName } userEmail avatarUrl roles { id label } } } }`,
}, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
});
const currentUser = profileRes.data?.data?.currentUser;
const workspaceMember = currentUser?.workspaceMember;
const roles = workspaceMember?.roles ?? [];
const roleLabels = roles.map((r: any) => r.label);
// Determine app role from platform roles
let appRole = 'executive'; // default
if (roleLabels.includes('HelixEngage Manager') || roleLabels.includes('HelixEngage Supervisor')) {
appRole = 'admin';
} else if (roleLabels.includes('HelixEngage User')) {
const email = workspaceMember?.userEmail ?? body.email;
appRole = email.includes('cc') ? 'cc-agent' : 'executive';
}
this.logger.log(`User ${body.email} logged in with role: ${appRole} (platform roles: ${roleLabels.join(', ')})`);
// Check if user has an Agent entity with SIP config — applies to ALL roles
let agentConfigResponse: any = undefined;
const memberId = workspaceMember?.id;
if (memberId) {
const agentConfig = await this.agentConfigService.getByMemberId(memberId);
if (agentConfig) {
// Agent entity found — set up SIP + Ozonetel
const clientIp = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ?? req.ip ?? 'unknown';
const existingSession = await this.sessionService.getSession(agentConfig.ozonetelAgentId);
if (existingSession) {
this.logger.warn(`Duplicate login blocked for ${body.email} — session held by IP ${existingSession.ip} since ${existingSession.lockedAt}`);
throw new HttpException(`You are already logged in from another device (${existingSession.ip}). Please log out there first.`, 409);
}
await this.sessionService.lockSession(agentConfig.ozonetelAgentId, memberId, clientIp);
this.ozonetelAgent.refreshToken().catch(err => {
this.logger.warn(`Ozonetel token refresh on login failed: ${err.message}`);
});
const ozAgentPassword = this.telephony.getConfig().ozonetel.agentPassword || 'Test123$';
this.ozonetelAgent.loginAgent({
agentId: agentConfig.ozonetelAgentId,
password: ozAgentPassword,
phoneNumber: agentConfig.sipExtension,
mode: 'blended',
}).catch(err => {
this.logger.warn(`Ozonetel agent login failed (non-blocking): ${err.message}`);
});
agentConfigResponse = {
ozonetelAgentId: agentConfig.ozonetelAgentId,
sipExtension: agentConfig.sipExtension,
sipPassword: agentConfig.sipPassword,
sipUri: agentConfig.sipUri,
sipWsServer: agentConfig.sipWsServer,
campaignName: agentConfig.campaignName,
};
this.logger.log(`Agent ${body.email} → Ozonetel ${agentConfig.ozonetelAgentId} / SIP ${agentConfig.sipExtension}`);
} else if (appRole === 'cc-agent') {
// CC agent role but no Agent entity — block login
throw new HttpException('Agent account not configured. Contact administrator.', 403);
} else {
this.logger.log(`User ${body.email} has no Agent entity — SIP disabled`);
}
}
// Cache agent name for worklist resolution (avoids re-querying currentUser with user JWT)
const agentFullName = `${workspaceMember?.name?.firstName ?? ''} ${workspaceMember?.name?.lastName ?? ''}`.trim();
if (agentFullName) {
await this.sessionService.setCache(`agent:name:${accessToken.slice(-16)}`, agentFullName, 86400);
}
return {
accessToken,
refreshToken: tokens.refreshToken.token,
user: {
id: currentUser?.id,
email: currentUser?.email,
firstName: workspaceMember?.name?.firstName ?? '',
lastName: workspaceMember?.name?.lastName ?? '',
avatarUrl: workspaceMember?.avatarUrl,
role: appRole,
platformRoles: roleLabels,
},
...(agentConfigResponse ? { agentConfig: agentConfigResponse } : {}),
};
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(`Login proxy failed: ${error}`);
throw new HttpException('Authentication service unavailable', 503);
}
}
@Post('refresh')
async refresh(@Body() body: { refreshToken: string }) {
if (!body.refreshToken) {
throw new HttpException('refreshToken required', 400);
}
this.logger.log('Token refresh request');
try {
const res = await axios.post(this.graphqlUrl, {
query: `mutation RefreshToken($token: String!) {
renewToken(appToken: $token) {
tokens {
accessOrWorkspaceAgnosticToken { token expiresAt }
refreshToken { token }
}
}
}`,
variables: { token: body.refreshToken },
}, {
headers: { 'Content-Type': 'application/json' },
});
if (res.data.errors) {
this.logger.warn(`Token refresh failed: ${res.data.errors[0]?.message}`);
throw new HttpException('Token refresh failed', 401);
}
const tokens = res.data.data.renewToken.tokens;
return {
accessToken: tokens.accessOrWorkspaceAgnosticToken.token,
refreshToken: tokens.refreshToken.token,
};
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(`Token refresh failed: ${error}`);
throw new HttpException('Token refresh failed', 401);
}
}
@Post('logout')
async logout(@Headers('authorization') auth: string) {
if (!auth) return { status: 'ok' };
try {
const profileRes = await axios.post(this.graphqlUrl, {
query: '{ currentUser { workspaceMember { id } } }',
}, { headers: { 'Content-Type': 'application/json', Authorization: auth } });
const memberId = profileRes.data?.data?.currentUser?.workspaceMember?.id;
if (!memberId) return { status: 'ok' };
const agentConfig = this.agentConfigService.getFromCache(memberId);
if (agentConfig) {
await this.sessionService.unlockSession(agentConfig.ozonetelAgentId);
this.logger.log(`Session unlocked for ${agentConfig.ozonetelAgentId}`);
this.ozonetelAgent.logoutAgent({
agentId: agentConfig.ozonetelAgentId,
password: this.telephony.getConfig().ozonetel.agentPassword || 'Test123$',
}).catch(err => this.logger.warn(`Ozonetel logout failed: ${err.message}`));
this.agentConfigService.clearCache(memberId);
}
return { status: 'ok' };
} catch (err) {
this.logger.warn(`Logout cleanup failed: ${err}`);
return { status: 'ok' };
}
}
@Post('heartbeat')
async heartbeat(@Headers('authorization') auth: string) {
if (!auth) return { status: 'ok' };
try {
const profileRes = await axios.post(this.graphqlUrl, {
query: '{ currentUser { workspaceMember { id } } }',
}, { headers: { 'Content-Type': 'application/json', Authorization: auth } });
const memberId = profileRes.data?.data?.currentUser?.workspaceMember?.id;
const agentConfig = memberId ? this.agentConfigService.getFromCache(memberId) : null;
if (agentConfig) {
await this.sessionService.refreshSession(agentConfig.ozonetelAgentId);
}
return { status: 'ok' };
} catch {
return { status: 'ok' };
}
}
}