bugfix: organization not selected problem fixed. being logged out too often fixed.
This commit is contained in:
@@ -26,6 +26,8 @@ import {
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
||||
@@ -46,7 +48,11 @@ export class AuthController {
|
||||
private static readonly REMEMBER_ME_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
private static readonly PASSWORD_RESET_MAX_AGE_MS = 15 * 60 * 1000;
|
||||
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
// =========================
|
||||
// LOGIN
|
||||
@@ -165,10 +171,20 @@ export class AuthController {
|
||||
@ApiResponse({ status: 200, description: 'Profile retrieved successfully' })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid or missing JWT token' })
|
||||
async getProfile(@Req() req) {
|
||||
console.log('Profile endpoint hit');
|
||||
console.log('USER FROM JWT:', req.user);
|
||||
const result = await this.authService.getProfile(req.user.id);
|
||||
const accessTokenExpiresAt = this.readAccessTokenExpiresAt(req);
|
||||
|
||||
return this.authService.getProfile(req.user.id);
|
||||
if (!accessTokenExpiresAt || !result.data) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
data: {
|
||||
...result.data,
|
||||
accessTokenExpiresAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Patch('profile/language')
|
||||
@@ -309,7 +325,10 @@ export class AuthController {
|
||||
throw new UnauthorizedException('Refresh token not found');
|
||||
}
|
||||
|
||||
const result = await this.authService.refreshToken(refreshToken);
|
||||
const result = await this.authService.refreshToken(
|
||||
refreshToken,
|
||||
req?.cookies?.accessToken,
|
||||
);
|
||||
|
||||
this.setAccessToken(
|
||||
res,
|
||||
@@ -321,10 +340,25 @@ export class AuthController {
|
||||
success: true,
|
||||
data: {
|
||||
accessToken: result.data.accessToken,
|
||||
accessTokenExpiresAt: result.data.accessTokenExpiresAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private readAccessTokenExpiresAt(req: { cookies?: { accessToken?: string } }): string | undefined {
|
||||
const token = req?.cookies?.accessToken;
|
||||
if (!token) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payload = this.jwtService.decode(token) as { exp?: number } | null;
|
||||
if (!payload?.exp) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return new Date(payload.exp * 1000).toISOString();
|
||||
}
|
||||
|
||||
// =========================
|
||||
// LOGOUT
|
||||
// =========================
|
||||
@@ -366,7 +400,7 @@ export class AuthController {
|
||||
private baseCookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure: false, // ⚠️ true in production (HTTPS)
|
||||
secure: this.configService.get<boolean>('cookie.secure') ?? false,
|
||||
sameSite: 'lax' as const,
|
||||
path: '/',
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
ForgotPasswordVerifyDto,
|
||||
} from './dto/forgot-password.dto';
|
||||
import { normalizeIranMobile } from '../../common/utils/mobile.util';
|
||||
import { sessionExpiresAtFromNow } from '../../common/jwt-duration';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
const FORGOT_PASSWORD_PURPOSE = 'forgot_password';
|
||||
@@ -97,6 +98,18 @@ export class AuthService {
|
||||
} as JwtSignOptions;
|
||||
}
|
||||
|
||||
private sessionExpiresAt(): Date {
|
||||
const refreshExpiresIn =
|
||||
this.configService.get<string>('jwt.refreshExpiresIn') ?? '30d';
|
||||
return sessionExpiresAtFromNow(refreshExpiresIn);
|
||||
}
|
||||
|
||||
private accessTokenExpiresAt(): Date {
|
||||
const accessExpiresIn =
|
||||
this.configService.get<string>('jwt.expiresIn') ?? '15m';
|
||||
return sessionExpiresAtFromNow(accessExpiresIn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate user credentials (used by LocalStrategy)
|
||||
* @param email - User's email
|
||||
@@ -182,7 +195,7 @@ export class AuthService {
|
||||
userId: user.id,
|
||||
token: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
|
||||
expiresAt: this.sessionExpiresAt(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -207,6 +220,7 @@ export class AuthService {
|
||||
data: {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
accessTokenExpiresAt: this.accessTokenExpiresAt().toISOString(),
|
||||
user: this.toPublicUser(user),
|
||||
organizations,
|
||||
},
|
||||
@@ -459,9 +473,10 @@ export class AuthService {
|
||||
/**
|
||||
* Refresh access token using refresh token
|
||||
* @param refreshToken - Valid refresh token
|
||||
* @param previousAccessToken - Expired access token used to preserve organization context
|
||||
* @returns New access token
|
||||
*/
|
||||
async refreshToken(refreshToken: string) {
|
||||
async refreshToken(refreshToken: string, previousAccessToken?: string) {
|
||||
try {
|
||||
// Verify the refresh token
|
||||
const payload = await this.jwtService.verifyAsync(refreshToken, {
|
||||
@@ -506,11 +521,17 @@ export class AuthService {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
|
||||
const organizationId = await this.resolveOrganizationIdForRefresh(
|
||||
previousAccessToken,
|
||||
session.user.memberships,
|
||||
);
|
||||
|
||||
// Generate new access token
|
||||
const newAccessPayload: JwtPayload = {
|
||||
sub: session.user.id,
|
||||
email: session.user.email,
|
||||
type: 'access',
|
||||
...(organizationId ? { organizationId } : {}),
|
||||
};
|
||||
|
||||
const newAccessToken = await this.jwtService.signAsync(
|
||||
@@ -523,7 +544,7 @@ export class AuthService {
|
||||
where: { id: session.id },
|
||||
data: {
|
||||
token: newAccessToken,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
|
||||
expiresAt: this.sessionExpiresAt(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -547,6 +568,7 @@ export class AuthService {
|
||||
success: true,
|
||||
data: {
|
||||
accessToken: newAccessToken,
|
||||
accessTokenExpiresAt: this.accessTokenExpiresAt().toISOString(),
|
||||
user: this.toPublicUser(session.user),
|
||||
organizations,
|
||||
},
|
||||
@@ -1163,6 +1185,50 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
private async decodeAccessOrganizationId(token?: string): Promise<string | undefined> {
|
||||
if (!token?.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = (await this.jwtService.verifyAsync(token, {
|
||||
secret: this.configService.get<string>('jwt.secret')!,
|
||||
ignoreExpiration: true,
|
||||
})) as JwtPayload;
|
||||
|
||||
return typeof payload.organizationId === 'string' ? payload.organizationId : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveOrganizationIdForRefresh(
|
||||
previousAccessToken: string | undefined,
|
||||
memberships: Array<{
|
||||
organizationId: string;
|
||||
isOwner: boolean;
|
||||
isActive: boolean;
|
||||
}>,
|
||||
): Promise<string | undefined> {
|
||||
const activeMemberships = memberships.filter((m) => m.isOwner || m.isActive);
|
||||
const candidateFromToken = await this.decodeAccessOrganizationId(previousAccessToken);
|
||||
|
||||
if (candidateFromToken) {
|
||||
const stillMember = activeMemberships.some(
|
||||
(membership) => membership.organizationId === candidateFromToken,
|
||||
);
|
||||
if (stillMember) {
|
||||
return candidateFromToken;
|
||||
}
|
||||
}
|
||||
|
||||
if (activeMemberships.length === 1) {
|
||||
return activeMemberships[0].organizationId;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async getOwnerMembership(userId: string, organizationId: string) {
|
||||
if (!organizationId) {
|
||||
throw new BadRequestException('Organization is not selected');
|
||||
|
||||
Reference in New Issue
Block a user