245 lines
6.1 KiB
TypeScript
245 lines
6.1 KiB
TypeScript
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
|
|
import { AuthPrismaService } from '../auth-prisma/auth-prisma.service';
|
|
import { ListUserSessionsQuery } from './dto';
|
|
|
|
type UserSessionRow = {
|
|
id: string;
|
|
sessionId: string | null;
|
|
userId: string;
|
|
user: string;
|
|
email: string;
|
|
avatarUrl: string | null;
|
|
role: string;
|
|
session: string;
|
|
createdAt: Date | null;
|
|
expiresAt: Date | null;
|
|
lastSeen: Date | null;
|
|
state: string;
|
|
primaryAffiliation: {
|
|
id: string;
|
|
organizationId: string;
|
|
organizationName: string;
|
|
organizationType: string;
|
|
department: string | null;
|
|
position: string | null;
|
|
} | null;
|
|
};
|
|
|
|
@Injectable()
|
|
export class UsersService {
|
|
constructor(
|
|
private readonly authPrisma: AuthPrismaService,
|
|
private readonly configService: ConfigService,
|
|
) {}
|
|
|
|
async listSessions(query: ListUserSessionsQuery) {
|
|
const keyword = query.keyword?.trim();
|
|
const now = new Date();
|
|
|
|
const users = await this.authPrisma.user.findMany({
|
|
where: keyword
|
|
? {
|
|
OR: [
|
|
{ email: { contains: keyword, mode: 'insensitive' } },
|
|
{ name: { contains: keyword, mode: 'insensitive' } },
|
|
],
|
|
}
|
|
: undefined,
|
|
orderBy: [{ lastLoginAt: 'desc' }, { createdAt: 'desc' }],
|
|
include: {
|
|
refreshTokens: {
|
|
where: {
|
|
revokedAt: null,
|
|
expiresAt: { gt: now },
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
},
|
|
affiliations: {
|
|
where: {
|
|
OR: [{ endsAt: null }, { endsAt: { gt: now } }],
|
|
},
|
|
orderBy: [{ isPrimary: 'desc' }, { createdAt: 'desc' }],
|
|
take: 1,
|
|
include: {
|
|
organization: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
type: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const rows: UserSessionRow[] = users.flatMap((user): UserSessionRow[] => {
|
|
const avatarUrl = getUserAvatarUrl(
|
|
user.id,
|
|
user.profileImageMime,
|
|
this.configService.get<string>('AUTH_PUBLIC_URL')
|
|
?? this.configService.get<string>('ATNMY_AUTH_URL')
|
|
?? this.configService.get<string>('AUTH_SERVER_URL'),
|
|
);
|
|
const primaryAffiliation = user.affiliations[0]
|
|
? {
|
|
id: user.affiliations[0].id,
|
|
organizationId: user.affiliations[0].organizationId,
|
|
organizationName: user.affiliations[0].organization.name,
|
|
organizationType: user.affiliations[0].organization.type,
|
|
department: user.affiliations[0].department,
|
|
position: user.affiliations[0].position,
|
|
}
|
|
: null;
|
|
|
|
if (user.refreshTokens.length === 0) {
|
|
return [
|
|
{
|
|
id: `user-${user.id}`,
|
|
sessionId: null,
|
|
userId: user.id,
|
|
user: user.name || user.email,
|
|
email: user.email,
|
|
avatarUrl,
|
|
role: user.role,
|
|
session: '세션 없음',
|
|
createdAt: null,
|
|
expiresAt: null,
|
|
lastSeen: user.lastLoginAt,
|
|
state: user.isActive ? '세션 없음' : '잠김',
|
|
primaryAffiliation,
|
|
},
|
|
];
|
|
}
|
|
|
|
return user.refreshTokens.map((token) => ({
|
|
id: token.id,
|
|
sessionId: token.id,
|
|
userId: user.id,
|
|
user: user.name || user.email,
|
|
email: user.email,
|
|
avatarUrl,
|
|
role: user.role,
|
|
session: token.tokenHash.slice(0, 10),
|
|
createdAt: token.createdAt,
|
|
expiresAt: token.expiresAt,
|
|
lastSeen: user.lastLoginAt ?? token.createdAt,
|
|
state: getSessionState(user.isActive, token.revokedAt, token.expiresAt, now),
|
|
primaryAffiliation,
|
|
}));
|
|
});
|
|
|
|
const filteredRows = query.state
|
|
? rows.filter((row) => row.state === query.state)
|
|
: rows;
|
|
|
|
return {
|
|
sessions: filteredRows.map((row, index) => ({
|
|
...row,
|
|
no: filteredRows.length - index,
|
|
})),
|
|
};
|
|
}
|
|
|
|
async lockUser(userId: string) {
|
|
const user = await this.authPrisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { id: true },
|
|
});
|
|
|
|
if (!user) {
|
|
throw new NotFoundException('사용자를 찾을 수 없습니다.');
|
|
}
|
|
|
|
await this.authPrisma.$transaction([
|
|
this.authPrisma.user.update({
|
|
where: { id: userId },
|
|
data: { isActive: false },
|
|
}),
|
|
this.authPrisma.refreshToken.updateMany({
|
|
where: { userId, revokedAt: null },
|
|
data: { revokedAt: new Date() },
|
|
}),
|
|
]);
|
|
|
|
return { ok: true };
|
|
}
|
|
|
|
async unlockUser(userId: string) {
|
|
const user = await this.authPrisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { id: true },
|
|
});
|
|
|
|
if (!user) {
|
|
throw new NotFoundException('사용자를 찾을 수 없습니다.');
|
|
}
|
|
|
|
await this.authPrisma.user.update({
|
|
where: { id: userId },
|
|
data: { isActive: true },
|
|
});
|
|
|
|
return { ok: true };
|
|
}
|
|
|
|
async terminateSession(sessionId: string) {
|
|
const session = await this.authPrisma.refreshToken.findUnique({
|
|
where: { id: sessionId },
|
|
select: { id: true, userId: true },
|
|
});
|
|
|
|
if (!session) {
|
|
throw new NotFoundException('세션을 찾을 수 없습니다.');
|
|
}
|
|
|
|
await this.authPrisma.refreshToken.updateMany({
|
|
where: {
|
|
userId: session.userId,
|
|
revokedAt: null,
|
|
expiresAt: { gt: new Date() },
|
|
},
|
|
data: { revokedAt: new Date() },
|
|
});
|
|
|
|
return { ok: true };
|
|
}
|
|
}
|
|
|
|
function getUserAvatarUrl(
|
|
userId: string,
|
|
mimeType?: string | null,
|
|
authBaseUrl?: string | null,
|
|
) {
|
|
if (!mimeType || !authBaseUrl) {
|
|
return null;
|
|
}
|
|
|
|
const normalizedBaseUrl = authBaseUrl.replace(/\/+$/, '');
|
|
|
|
return `${normalizedBaseUrl}/auth/users/${encodeURIComponent(userId)}/avatar`;
|
|
}
|
|
|
|
function getSessionState(
|
|
isUserActive: boolean,
|
|
revokedAt: Date | null,
|
|
expiresAt: Date,
|
|
now: Date,
|
|
) {
|
|
if (!isUserActive) {
|
|
return '잠김';
|
|
}
|
|
|
|
if (revokedAt) {
|
|
return '종료';
|
|
}
|
|
|
|
if (expiresAt < now) {
|
|
return '만료';
|
|
}
|
|
|
|
return '유효토큰';
|
|
}
|