Add Flutter auth sessions to teams
All checks were successful
Build Docker Image / build (push) Successful in 2m10s
All checks were successful
Build Docker Image / build (push) Successful in 2m10s
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
import { GraphQLError } from 'graphql'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { prisma } from '../db.js'
|
||||
import type { AuthContext } from '../auth.js'
|
||||
import { SESSION_TOKEN_PREFIX, 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 {
|
||||
@@ -15,13 +20,53 @@ export const userTypeDefs = `#graphql
|
||||
id: String
|
||||
firstName: String
|
||||
lastName: String
|
||||
displayName: String
|
||||
phone: String
|
||||
avatarId: String
|
||||
isAdmin: 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!
|
||||
}
|
||||
|
||||
type ManagerUser {
|
||||
id: ID!
|
||||
accountId: String
|
||||
displayName: String
|
||||
phone: String!
|
||||
companyName: String
|
||||
telegramGroupTitle: String
|
||||
telegramNotificationsEnabled: Boolean!
|
||||
}
|
||||
|
||||
input RequestLoginOtpInput {
|
||||
phone: String!
|
||||
}
|
||||
|
||||
input VerifyLoginOtpInput {
|
||||
phone: String!
|
||||
code: String!
|
||||
}
|
||||
|
||||
input AdminTotpLoginInput {
|
||||
phone: String!
|
||||
code: String!
|
||||
}
|
||||
|
||||
type TeamMemberInfo {
|
||||
uuid: String!
|
||||
role: String!
|
||||
@@ -73,10 +118,15 @@ export const userTypeDefs = `#graphql
|
||||
|
||||
type Query {
|
||||
me: User
|
||||
managerUsers: [ManagerUser!]!
|
||||
getTeam(teamId: String!): TeamWithMembers
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
requestLoginOtp(input: RequestLoginOtpInput!): AuthChallenge!
|
||||
verifyLoginOtp(input: VerifyLoginOtpInput!): AuthPayload!
|
||||
adminTotpLogin(input: AdminTotpLoginInput!): AuthPayload!
|
||||
logout: Boolean!
|
||||
createTeam(input: CreateTeamInput!): CreateTeamResult
|
||||
updateUser(userId: String!, input: UpdateUserInput!): UpdateUserResult
|
||||
switchTeam(teamId: String!): SwitchTeamResult
|
||||
@@ -95,22 +145,119 @@ async function getOrCreateProfile(logtoId: string) {
|
||||
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 isAdminUser(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 } })
|
||||
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,
|
||||
isAdmin: await isAdminUser(profile.userId),
|
||||
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 issueSession(userId: number) {
|
||||
const token = `${SESSION_TOKEN_PREFIX}${randomBytes(32).toString('hex')}`
|
||||
const expiresAt = new Date(Date.now() + SESSION_TTL_DAYS * 24 * 60 * 60 * 1000)
|
||||
await prisma.authSession.create({ data: { token, userId, expiresAt } })
|
||||
return { token, expiresAt }
|
||||
}
|
||||
|
||||
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 session = await issueSession(profile.userId)
|
||||
return {
|
||||
token: session.token,
|
||||
sessionExpiresAt: session.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)
|
||||
const memberships = await prisma.teamMember.findMany({ where: { userId: profile.userId }, include: { team: true } })
|
||||
return {
|
||||
id: ctx.userId,
|
||||
firstName: profile.user.firstName,
|
||||
lastName: profile.user.lastName,
|
||||
phone: profile.phone,
|
||||
avatarId: profile.avatarId,
|
||||
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() })),
|
||||
}
|
||||
return mapProfileUser(profile)
|
||||
},
|
||||
|
||||
managerUsers: async (_: unknown, __: unknown, ctx: AuthContext) => {
|
||||
if (!ctx.userId) throw new GraphQLError('Not authenticated')
|
||||
const profile = await getOrCreateProfile(ctx.userId)
|
||||
if (!(await isAdminUser(profile.userId))) return []
|
||||
const members = await prisma.teamMember.findMany({
|
||||
where: profile.activeTeamId === null ? {} : { teamId: profile.activeTeamId },
|
||||
include: { user: { include: { profile: { include: { activeTeam: true } } } }, team: true },
|
||||
orderBy: { joinedAt: 'desc' },
|
||||
})
|
||||
return members.filter(member => member.user !== null).map(member => {
|
||||
const user = member.user!
|
||||
const memberProfile = user.profile
|
||||
const phone = memberProfile?.phone ?? ''
|
||||
return {
|
||||
id: member.uuid,
|
||||
accountId: memberProfile?.logtoId ?? user.username,
|
||||
displayName: displayName(user.firstName, user.lastName, phone),
|
||||
phone,
|
||||
companyName: member.team.name,
|
||||
telegramGroupTitle: null,
|
||||
telegramNotificationsEnabled: false,
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
getTeam: async (_: unknown, args: { teamId: string }, ctx: AuthContext) => {
|
||||
@@ -137,6 +284,34 @@ export const userResolvers = {
|
||||
},
|
||||
|
||||
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)
|
||||
},
|
||||
|
||||
adminTotpLogin: 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)
|
||||
|
||||
Reference in New Issue
Block a user