307 lines
10 KiB
TypeScript
307 lines
10 KiB
TypeScript
import { GraphQLError } from 'graphql'
|
|
import { prisma } from '../db.js'
|
|
import { issueAppJwt, type AuthContext } from '../auth.js'
|
|
|
|
const OTP_TTL_MINUTES = Number.parseInt(process.env.LOGIN_OTP_TTL_MINUTES || '10', 10)
|
|
const SESSION_TTL_DAYS = Number.parseInt(process.env.LOGIN_SESSION_TTL_DAYS || '30', 10)
|
|
const DEV_LOGIN_OTP_CODE = process.env.LOGIN_OTP_DEV_CODE || '000000'
|
|
|
|
export const userTypeDefs = `#graphql
|
|
type UserTeam {
|
|
id: String!
|
|
name: String!
|
|
teamType: String
|
|
logtoOrgId: String
|
|
createdAt: String
|
|
}
|
|
|
|
type User {
|
|
id: String
|
|
firstName: String
|
|
lastName: String
|
|
displayName: String
|
|
phone: String
|
|
avatarId: String
|
|
isManager: Boolean
|
|
activeTeamId: String
|
|
activeTeam: UserTeam
|
|
teams: [UserTeam]
|
|
}
|
|
|
|
type AuthChallenge {
|
|
challengeType: String!
|
|
phone: String!
|
|
expiresAt: String!
|
|
codeForDev: String
|
|
totpConfigured: Boolean!
|
|
}
|
|
|
|
type AuthPayload {
|
|
token: String!
|
|
sessionExpiresAt: String!
|
|
user: User!
|
|
}
|
|
|
|
input RequestLoginOtpInput {
|
|
phone: String!
|
|
}
|
|
|
|
input VerifyLoginOtpInput {
|
|
phone: String!
|
|
code: String!
|
|
}
|
|
|
|
type TeamMemberInfo {
|
|
uuid: String!
|
|
role: String!
|
|
joinedAt: String
|
|
}
|
|
|
|
type TeamInvitationInfo {
|
|
uuid: String!
|
|
email: String!
|
|
role: String!
|
|
status: String!
|
|
invitedBy: String
|
|
expiresAt: String
|
|
createdAt: String
|
|
}
|
|
|
|
type TeamWithMembers {
|
|
uuid: String!
|
|
name: String!
|
|
members: [TeamMemberInfo]
|
|
invitations: [TeamInvitationInfo]
|
|
}
|
|
|
|
input CreateTeamInput {
|
|
name: String!
|
|
teamType: String
|
|
}
|
|
|
|
input UpdateUserInput {
|
|
firstName: String
|
|
lastName: String
|
|
phone: String
|
|
avatarId: String
|
|
}
|
|
|
|
type CreateTeamResult {
|
|
team: UserTeam
|
|
success: Boolean!
|
|
}
|
|
|
|
type UpdateUserResult {
|
|
user: User
|
|
success: Boolean!
|
|
}
|
|
|
|
type SwitchTeamResult {
|
|
success: Boolean!
|
|
}
|
|
|
|
type Query {
|
|
me: User
|
|
getTeam(teamId: String!): TeamWithMembers
|
|
}
|
|
|
|
type Mutation {
|
|
requestLoginOtp(input: RequestLoginOtpInput!): AuthChallenge!
|
|
verifyLoginOtp(input: VerifyLoginOtpInput!): AuthPayload!
|
|
logout: Boolean!
|
|
createTeam(input: CreateTeamInput!): CreateTeamResult
|
|
updateUser(userId: String!, input: UpdateUserInput!): UpdateUserResult
|
|
switchTeam(teamId: String!): SwitchTeamResult
|
|
}
|
|
`
|
|
|
|
async function getOrCreateProfile(logtoId: string) {
|
|
let profile = await prisma.userProfile.findUnique({ where: { logtoId: logtoId }, include: { user: true, activeTeam: true } })
|
|
if (!profile) {
|
|
const user = await prisma.user.create({ data: { username: logtoId, firstName: '', lastName: '' } })
|
|
profile = await prisma.userProfile.create({
|
|
data: { userId: user.id, logtoId: logtoId },
|
|
include: { user: true, activeTeam: true },
|
|
})
|
|
}
|
|
return profile
|
|
}
|
|
|
|
function normalizePhone(phone: string): string {
|
|
const normalized = phone.replace(/[^\d+]/g, '')
|
|
if (normalized.length < 5) throw new GraphQLError('Invalid phone')
|
|
return normalized
|
|
}
|
|
|
|
function displayName(firstName: string, lastName: string, phone: string): string {
|
|
const name = `${firstName} ${lastName}`.trim()
|
|
return name.length > 0 ? name : phone
|
|
}
|
|
|
|
async function getOrCreateProfileByPhone(phone: string) {
|
|
const logtoId = `phone:${phone}`
|
|
const existing = await prisma.userProfile.findFirst({
|
|
where: { OR: [{ phone }, { logtoId }] },
|
|
include: { user: true, activeTeam: true },
|
|
})
|
|
if (existing !== null) return existing
|
|
|
|
const user = await prisma.user.create({ data: { username: logtoId, firstName: '', lastName: '' } })
|
|
return prisma.userProfile.create({
|
|
data: { userId: user.id, logtoId, phone },
|
|
include: { user: true, activeTeam: true },
|
|
})
|
|
}
|
|
|
|
async function isManagerUser(userId: number): Promise<boolean> {
|
|
const user = await prisma.user.findUnique({ where: { id: userId } })
|
|
if (user === null) return false
|
|
if (user.isStaff || user.isSuperuser) return true
|
|
const managerMembership = await prisma.teamMember.findFirst({
|
|
where: { userId, role: { in: ['OWNER', 'MANAGER', 'ADMIN'] } },
|
|
})
|
|
return managerMembership !== null
|
|
}
|
|
|
|
async function mapProfileUser(profile: Awaited<ReturnType<typeof getOrCreateProfile>>) {
|
|
const memberships = await prisma.teamMember.findMany({ where: { userId: profile.userId }, include: { team: true } })
|
|
const isManager = await isManagerUser(profile.userId)
|
|
return {
|
|
id: profile.logtoId,
|
|
firstName: profile.user.firstName,
|
|
lastName: profile.user.lastName,
|
|
displayName: displayName(profile.user.firstName, profile.user.lastName, profile.phone),
|
|
phone: profile.phone,
|
|
avatarId: profile.avatarId,
|
|
isManager,
|
|
activeTeamId: profile.activeTeam?.uuid ?? null,
|
|
activeTeam: profile.activeTeam ? { id: profile.activeTeam.uuid, name: profile.activeTeam.name, teamType: profile.activeTeam.teamType, logtoOrgId: profile.activeTeam.logtoOrgId, createdAt: profile.activeTeam.createdAt.toISOString() } : null,
|
|
teams: memberships.map(m => ({ id: m.team.uuid, name: m.team.name, teamType: m.team.teamType, logtoOrgId: m.team.logtoOrgId, createdAt: m.team.createdAt.toISOString() })),
|
|
}
|
|
}
|
|
|
|
async function verifyPhoneLogin(phone: string, code: string) {
|
|
const normalizedPhone = normalizePhone(phone)
|
|
const challenge = await prisma.loginChallenge.findFirst({
|
|
where: {
|
|
phone: normalizedPhone,
|
|
code,
|
|
usedAt: null,
|
|
expiresAt: { gt: new Date() },
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
})
|
|
if (challenge === null) throw new GraphQLError('Invalid login code')
|
|
|
|
await prisma.loginChallenge.update({ where: { id: challenge.id }, data: { usedAt: new Date() } })
|
|
const profile = await getOrCreateProfileByPhone(normalizedPhone)
|
|
const isManager = await isManagerUser(profile.userId)
|
|
const token = await issueAppJwt({ userId: profile.logtoId, teamUuid: profile.activeTeam?.uuid ?? null, isManager })
|
|
const expiresAt = new Date(Date.now() + SESSION_TTL_DAYS * 24 * 60 * 60 * 1000)
|
|
return {
|
|
token,
|
|
sessionExpiresAt: expiresAt.toISOString(),
|
|
user: await mapProfileUser(profile),
|
|
}
|
|
}
|
|
|
|
export const userResolvers = {
|
|
Query: {
|
|
me: async (_: unknown, __: unknown, ctx: AuthContext) => {
|
|
if (!ctx.userId) throw new GraphQLError('Not authenticated')
|
|
const profile = await getOrCreateProfile(ctx.userId)
|
|
return mapProfileUser(profile)
|
|
},
|
|
|
|
getTeam: async (_: unknown, args: { teamId: string }, ctx: AuthContext) => {
|
|
if (!ctx.userId) throw new GraphQLError('Not authenticated')
|
|
const team = await prisma.team.findUnique({
|
|
where: { uuid: args.teamId },
|
|
include: {
|
|
members: { include: { user: true } },
|
|
invitations: true,
|
|
},
|
|
})
|
|
if (!team) return null
|
|
return {
|
|
uuid: team.uuid,
|
|
name: team.name,
|
|
members: team.members.map(m => ({ uuid: m.uuid, role: m.role, joinedAt: m.joinedAt.toISOString() })),
|
|
invitations: team.invitations.map(i => ({
|
|
uuid: i.uuid, email: i.email, role: i.role, status: i.status,
|
|
invitedBy: i.invitedBy, expiresAt: i.expiresAt?.toISOString() ?? null,
|
|
createdAt: i.createdAt.toISOString(),
|
|
})),
|
|
}
|
|
},
|
|
},
|
|
|
|
Mutation: {
|
|
requestLoginOtp: async (_: unknown, args: { input: { phone: string } }) => {
|
|
const phone = normalizePhone(args.input.phone)
|
|
const expiresAt = new Date(Date.now() + OTP_TTL_MINUTES * 60 * 1000)
|
|
await prisma.loginChallenge.create({ data: { phone, code: DEV_LOGIN_OTP_CODE, expiresAt } })
|
|
return {
|
|
challengeType: 'otp',
|
|
phone,
|
|
expiresAt: expiresAt.toISOString(),
|
|
codeForDev: process.env.NODE_ENV === 'production' ? null : DEV_LOGIN_OTP_CODE,
|
|
totpConfigured: true,
|
|
}
|
|
},
|
|
|
|
verifyLoginOtp: async (_: unknown, args: { input: { phone: string; code: string } }) => {
|
|
return verifyPhoneLogin(args.input.phone, args.input.code)
|
|
},
|
|
|
|
logout: async (_: unknown, __: unknown, ctx: AuthContext) => {
|
|
if (ctx.sessionToken !== undefined) {
|
|
await prisma.authSession.updateMany({ where: { token: ctx.sessionToken, revokedAt: null }, data: { revokedAt: new Date() } })
|
|
}
|
|
return true
|
|
},
|
|
|
|
createTeam: async (_: unknown, args: { input: { name: string; teamType?: string } }, ctx: AuthContext) => {
|
|
if (!ctx.userId) throw new GraphQLError('Not authenticated')
|
|
const profile = await getOrCreateProfile(ctx.userId)
|
|
const team = await prisma.team.create({
|
|
data: { name: args.input.name, teamType: args.input.teamType || 'BUYER', ownerId: profile.userId },
|
|
})
|
|
await prisma.teamMember.create({ data: { teamId: team.id, userId: profile.userId, role: 'OWNER' } })
|
|
await prisma.userProfile.update({ where: { id: profile.id }, data: { activeTeamId: team.id } })
|
|
|
|
return {
|
|
team: { id: team.uuid, name: team.name, teamType: team.teamType, logtoOrgId: team.logtoOrgId, createdAt: team.createdAt.toISOString() },
|
|
success: true,
|
|
}
|
|
},
|
|
|
|
updateUser: async (_: unknown, args: { userId: string; input: { firstName?: string; lastName?: string; phone?: string; avatarId?: string } }, ctx: AuthContext) => {
|
|
if (!ctx.userId) throw new GraphQLError('Not authenticated')
|
|
const profile = await getOrCreateProfile(ctx.userId)
|
|
const userUpdate: Record<string, unknown> = {}
|
|
if (args.input.firstName !== undefined) userUpdate.firstName = args.input.firstName
|
|
if (args.input.lastName !== undefined) userUpdate.lastName = args.input.lastName
|
|
if (Object.keys(userUpdate).length > 0) {
|
|
await prisma.user.update({ where: { id: profile.userId }, data: userUpdate })
|
|
}
|
|
const profileUpdate: Record<string, unknown> = {}
|
|
if (args.input.phone !== undefined) profileUpdate.phone = args.input.phone
|
|
if (args.input.avatarId !== undefined) profileUpdate.avatarId = args.input.avatarId
|
|
if (Object.keys(profileUpdate).length > 0) {
|
|
await prisma.userProfile.update({ where: { id: profile.id }, data: profileUpdate })
|
|
}
|
|
return { user: { id: ctx.userId }, success: true }
|
|
},
|
|
|
|
switchTeam: async (_: unknown, args: { teamId: string }, ctx: AuthContext) => {
|
|
if (!ctx.userId) throw new GraphQLError('Not authenticated')
|
|
const profile = await getOrCreateProfile(ctx.userId)
|
|
const team = await prisma.team.findUnique({ where: { uuid: args.teamId } })
|
|
if (!team) throw new GraphQLError('Team not found')
|
|
await prisma.userProfile.update({ where: { id: profile.id }, data: { activeTeamId: team.id } })
|
|
return { success: true }
|
|
},
|
|
},
|
|
}
|