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:
40
prisma/migrations/1_add_flutter_auth_sessions/migration.sql
Normal file
40
prisma/migrations/1_add_flutter_auth_sessions/migration.sql
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "teams_app_loginchallenge" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"uuid" TEXT NOT NULL,
|
||||||
|
"phone" VARCHAR(50) NOT NULL,
|
||||||
|
"code" VARCHAR(20) NOT NULL,
|
||||||
|
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"used_at" TIMESTAMP(3),
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "teams_app_loginchallenge_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "teams_app_authsession" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"token" VARCHAR(255) NOT NULL,
|
||||||
|
"user_id" INTEGER NOT NULL,
|
||||||
|
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"revoked_at" TIMESTAMP(3),
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "teams_app_authsession_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "teams_app_loginchallenge_uuid_key" ON "teams_app_loginchallenge"("uuid");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "teams_app_loginchallenge_phone_idx" ON "teams_app_loginchallenge"("phone");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "teams_app_authsession_token_key" ON "teams_app_authsession"("token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "teams_app_authsession_user_id_idx" ON "teams_app_authsession"("user_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "teams_app_authsession" ADD CONSTRAINT "teams_app_authsession_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth_user"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -8,20 +8,20 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Team {
|
model Team {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
uuid String @unique @default(uuid())
|
uuid String @unique @default(uuid())
|
||||||
name String @db.VarChar(200)
|
name String @db.VarChar(200)
|
||||||
teamType String @default("BUYER") @map("team_type") @db.VarChar(20)
|
teamType String @default("BUYER") @map("team_type") @db.VarChar(20)
|
||||||
logtoOrgId String? @map("logto_org_id") @db.VarChar(100)
|
logtoOrgId String? @map("logto_org_id") @db.VarChar(100)
|
||||||
ownerId Int? @map("owner_id")
|
ownerId Int? @map("owner_id")
|
||||||
owner User? @relation(fields: [ownerId], references: [id])
|
owner User? @relation(fields: [ownerId], references: [id])
|
||||||
selectedLocationType String? @map("selected_location_type") @db.VarChar(20)
|
selectedLocationType String? @map("selected_location_type") @db.VarChar(20)
|
||||||
selectedLocationUuid String? @map("selected_location_uuid") @db.VarChar(100)
|
selectedLocationUuid String? @map("selected_location_uuid") @db.VarChar(100)
|
||||||
selectedLocationName String? @map("selected_location_name") @db.VarChar(255)
|
selectedLocationName String? @map("selected_location_name") @db.VarChar(255)
|
||||||
selectedLocationLat Float? @map("selected_location_lat")
|
selectedLocationLat Float? @map("selected_location_lat")
|
||||||
selectedLocationLon Float? @map("selected_location_lon")
|
selectedLocationLon Float? @map("selected_location_lon")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
members TeamMember[]
|
members TeamMember[]
|
||||||
invitations TeamInvitation[]
|
invitations TeamInvitation[]
|
||||||
@@ -32,40 +32,67 @@ model Team {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
password String @default("") @db.VarChar(128)
|
password String @default("") @db.VarChar(128)
|
||||||
username String @unique @db.VarChar(150)
|
username String @unique @db.VarChar(150)
|
||||||
firstName String @default("") @map("first_name") @db.VarChar(150)
|
firstName String @default("") @map("first_name") @db.VarChar(150)
|
||||||
lastName String @default("") @map("last_name") @db.VarChar(150)
|
lastName String @default("") @map("last_name") @db.VarChar(150)
|
||||||
email String @default("") @db.VarChar(254)
|
email String @default("") @db.VarChar(254)
|
||||||
isActive Boolean @default(true) @map("is_active")
|
isActive Boolean @default(true) @map("is_active")
|
||||||
dateJoined DateTime @default(now()) @map("date_joined")
|
dateJoined DateTime @default(now()) @map("date_joined")
|
||||||
isSuperuser Boolean @default(false) @map("is_superuser")
|
isSuperuser Boolean @default(false) @map("is_superuser")
|
||||||
isStaff Boolean @default(false) @map("is_staff")
|
isStaff Boolean @default(false) @map("is_staff")
|
||||||
lastLogin DateTime? @map("last_login")
|
lastLogin DateTime? @map("last_login")
|
||||||
|
|
||||||
profile UserProfile?
|
profile UserProfile?
|
||||||
teams Team[]
|
sessions AuthSession[]
|
||||||
|
teams Team[]
|
||||||
memberships TeamMember[]
|
memberships TeamMember[]
|
||||||
|
|
||||||
@@map("auth_user")
|
@@map("auth_user")
|
||||||
}
|
}
|
||||||
|
|
||||||
model UserProfile {
|
model UserProfile {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId Int @unique @map("user_id")
|
userId Int @unique @map("user_id")
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
logtoId String @unique @map("logto_id") @db.VarChar(255)
|
logtoId String @unique @map("logto_id") @db.VarChar(255)
|
||||||
avatarId String? @map("avatar_id") @db.VarChar(255)
|
avatarId String? @map("avatar_id") @db.VarChar(255)
|
||||||
phone String @default("") @db.VarChar(50)
|
phone String @default("") @db.VarChar(50)
|
||||||
activeTeamId Int? @map("active_team_id")
|
activeTeamId Int? @map("active_team_id")
|
||||||
activeTeam Team? @relation("ActiveTeam", fields: [activeTeamId], references: [id])
|
activeTeam Team? @relation("ActiveTeam", fields: [activeTeamId], references: [id])
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
@@map("teams_app_userprofile")
|
@@map("teams_app_userprofile")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model LoginChallenge {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
uuid String @unique @default(uuid())
|
||||||
|
phone String @db.VarChar(50)
|
||||||
|
code String @db.VarChar(20)
|
||||||
|
expiresAt DateTime @map("expires_at")
|
||||||
|
usedAt DateTime? @map("used_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@index([phone])
|
||||||
|
@@map("teams_app_loginchallenge")
|
||||||
|
}
|
||||||
|
|
||||||
|
model AuthSession {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
token String @unique @db.VarChar(255)
|
||||||
|
userId Int @map("user_id")
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
expiresAt DateTime @map("expires_at")
|
||||||
|
revokedAt DateTime? @map("revoked_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@map("teams_app_authsession")
|
||||||
|
}
|
||||||
|
|
||||||
model TeamMember {
|
model TeamMember {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
uuid String @unique @default(uuid())
|
uuid String @unique @default(uuid())
|
||||||
@@ -81,16 +108,16 @@ model TeamMember {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model TeamInvitation {
|
model TeamInvitation {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
uuid String @unique @default(uuid())
|
uuid String @unique @default(uuid())
|
||||||
teamId Int @map("team_id")
|
teamId Int @map("team_id")
|
||||||
team Team @relation(fields: [teamId], references: [id])
|
team Team @relation(fields: [teamId], references: [id])
|
||||||
email String @db.VarChar(254)
|
email String @db.VarChar(254)
|
||||||
role String @default("MEMBER") @db.VarChar(20)
|
role String @default("MEMBER") @db.VarChar(20)
|
||||||
status String @default("PENDING") @db.VarChar(20)
|
status String @default("PENDING") @db.VarChar(20)
|
||||||
invitedBy String @default("") @map("invited_by") @db.VarChar(255)
|
invitedBy String @default("") @map("invited_by") @db.VarChar(255)
|
||||||
expiresAt DateTime? @map("expires_at")
|
expiresAt DateTime? @map("expires_at")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
tokens TeamInvitationToken[]
|
tokens TeamInvitationToken[]
|
||||||
|
|
||||||
@@ -99,14 +126,14 @@ model TeamInvitation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model TeamInvitationToken {
|
model TeamInvitationToken {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
uuid String @unique @default(uuid())
|
uuid String @unique @default(uuid())
|
||||||
invitationId Int @map("invitation_id")
|
invitationId Int @map("invitation_id")
|
||||||
invitation TeamInvitation @relation(fields: [invitationId], references: [id])
|
invitation TeamInvitation @relation(fields: [invitationId], references: [id])
|
||||||
tokenHash String @unique @map("token_hash") @db.VarChar(255)
|
tokenHash String @unique @map("token_hash") @db.VarChar(255)
|
||||||
workflowStatus String @default("pending") @map("workflow_status") @db.VarChar(20)
|
workflowStatus String @default("pending") @map("workflow_status") @db.VarChar(20)
|
||||||
expiresAt DateTime? @map("expires_at")
|
expiresAt DateTime? @map("expires_at")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
@@map("teams_app_teaminvitationtoken")
|
@@map("teams_app_teaminvitationtoken")
|
||||||
}
|
}
|
||||||
|
|||||||
22
src/auth.ts
22
src/auth.ts
@@ -1,6 +1,7 @@
|
|||||||
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'
|
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'
|
||||||
import { GraphQLError } from 'graphql'
|
import { GraphQLError } from 'graphql'
|
||||||
import type { Request } from 'express'
|
import type { Request } from 'express'
|
||||||
|
import { prisma } from './db.js'
|
||||||
|
|
||||||
const LOGTO_JWKS_URL = process.env.LOGTO_JWKS_URL || 'https://auth.optovia.ru/oidc/jwks'
|
const LOGTO_JWKS_URL = process.env.LOGTO_JWKS_URL || 'https://auth.optovia.ru/oidc/jwks'
|
||||||
const LOGTO_ISSUER = process.env.LOGTO_ISSUER || 'https://auth.optovia.ru/oidc'
|
const LOGTO_ISSUER = process.env.LOGTO_ISSUER || 'https://auth.optovia.ru/oidc'
|
||||||
@@ -11,10 +12,13 @@ const jwks = createRemoteJWKSet(new URL(LOGTO_JWKS_URL))
|
|||||||
export interface AuthContext {
|
export interface AuthContext {
|
||||||
userId?: string
|
userId?: string
|
||||||
teamUuid?: string
|
teamUuid?: string
|
||||||
|
sessionToken?: string
|
||||||
scopes: string[]
|
scopes: string[]
|
||||||
isM2M?: boolean
|
isM2M?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const SESSION_TOKEN_PREFIX = 'optovia-session:'
|
||||||
|
|
||||||
function getBearerToken(req: Request): string {
|
function getBearerToken(req: Request): string {
|
||||||
const auth = req.headers.authorization || ''
|
const auth = req.headers.authorization || ''
|
||||||
if (!auth.startsWith('Bearer ')) throw new GraphQLError('Missing Bearer token', { extensions: { code: 'UNAUTHENTICATED' } })
|
if (!auth.startsWith('Bearer ')) throw new GraphQLError('Missing Bearer token', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||||
@@ -23,6 +27,14 @@ function getBearerToken(req: Request): string {
|
|||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function optionalBearerToken(req: Request): string | null {
|
||||||
|
const auth = req.headers.authorization || ''
|
||||||
|
if (!auth.startsWith('Bearer ')) return null
|
||||||
|
const token = auth.slice(7)
|
||||||
|
if (!token || token === 'undefined') return null
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
function scopesFromPayload(payload: JWTPayload): string[] {
|
function scopesFromPayload(payload: JWTPayload): string[] {
|
||||||
const scope = payload.scope
|
const scope = payload.scope
|
||||||
if (!scope) return []
|
if (!scope) return []
|
||||||
@@ -33,7 +45,15 @@ function scopesFromPayload(payload: JWTPayload): string[] {
|
|||||||
export async function publicContext(): Promise<AuthContext> { return { scopes: [] } }
|
export async function publicContext(): Promise<AuthContext> { return { scopes: [] } }
|
||||||
|
|
||||||
export async function userContext(req: Request): Promise<AuthContext> {
|
export async function userContext(req: Request): Promise<AuthContext> {
|
||||||
const token = getBearerToken(req)
|
const token = optionalBearerToken(req)
|
||||||
|
if (token === null) return { scopes: [] }
|
||||||
|
if (token.startsWith(SESSION_TOKEN_PREFIX)) {
|
||||||
|
const session = await prisma.authSession.findUnique({ where: { token }, include: { user: true } })
|
||||||
|
if (session === null || session.revokedAt !== null || session.expiresAt <= new Date()) {
|
||||||
|
throw new GraphQLError('Session expired', { extensions: { code: 'UNAUTHENTICATED' } })
|
||||||
|
}
|
||||||
|
return { userId: session.user.username, sessionToken: token, scopes: ['teams:user'] }
|
||||||
|
}
|
||||||
const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER })
|
const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER })
|
||||||
return { userId: payload.sub, scopes: [] }
|
return { userId: payload.sub, scopes: [] }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { GraphQLError } from 'graphql'
|
import { GraphQLError } from 'graphql'
|
||||||
|
import { randomBytes } from 'crypto'
|
||||||
import { prisma } from '../db.js'
|
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
|
export const userTypeDefs = `#graphql
|
||||||
type UserTeam {
|
type UserTeam {
|
||||||
@@ -15,13 +20,53 @@ export const userTypeDefs = `#graphql
|
|||||||
id: String
|
id: String
|
||||||
firstName: String
|
firstName: String
|
||||||
lastName: String
|
lastName: String
|
||||||
|
displayName: String
|
||||||
phone: String
|
phone: String
|
||||||
avatarId: String
|
avatarId: String
|
||||||
|
isAdmin: Boolean
|
||||||
activeTeamId: String
|
activeTeamId: String
|
||||||
activeTeam: UserTeam
|
activeTeam: UserTeam
|
||||||
teams: [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 {
|
type TeamMemberInfo {
|
||||||
uuid: String!
|
uuid: String!
|
||||||
role: String!
|
role: String!
|
||||||
@@ -73,10 +118,15 @@ export const userTypeDefs = `#graphql
|
|||||||
|
|
||||||
type Query {
|
type Query {
|
||||||
me: User
|
me: User
|
||||||
|
managerUsers: [ManagerUser!]!
|
||||||
getTeam(teamId: String!): TeamWithMembers
|
getTeam(teamId: String!): TeamWithMembers
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
|
requestLoginOtp(input: RequestLoginOtpInput!): AuthChallenge!
|
||||||
|
verifyLoginOtp(input: VerifyLoginOtpInput!): AuthPayload!
|
||||||
|
adminTotpLogin(input: AdminTotpLoginInput!): AuthPayload!
|
||||||
|
logout: Boolean!
|
||||||
createTeam(input: CreateTeamInput!): CreateTeamResult
|
createTeam(input: CreateTeamInput!): CreateTeamResult
|
||||||
updateUser(userId: String!, input: UpdateUserInput!): UpdateUserResult
|
updateUser(userId: String!, input: UpdateUserInput!): UpdateUserResult
|
||||||
switchTeam(teamId: String!): SwitchTeamResult
|
switchTeam(teamId: String!): SwitchTeamResult
|
||||||
@@ -95,22 +145,119 @@ async function getOrCreateProfile(logtoId: string) {
|
|||||||
return profile
|
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 = {
|
export const userResolvers = {
|
||||||
Query: {
|
Query: {
|
||||||
me: async (_: unknown, __: unknown, ctx: AuthContext) => {
|
me: async (_: unknown, __: unknown, ctx: AuthContext) => {
|
||||||
if (!ctx.userId) throw new GraphQLError('Not authenticated')
|
if (!ctx.userId) throw new GraphQLError('Not authenticated')
|
||||||
const profile = await getOrCreateProfile(ctx.userId)
|
const profile = await getOrCreateProfile(ctx.userId)
|
||||||
const memberships = await prisma.teamMember.findMany({ where: { userId: profile.userId }, include: { team: true } })
|
return mapProfileUser(profile)
|
||||||
return {
|
},
|
||||||
id: ctx.userId,
|
|
||||||
firstName: profile.user.firstName,
|
managerUsers: async (_: unknown, __: unknown, ctx: AuthContext) => {
|
||||||
lastName: profile.user.lastName,
|
if (!ctx.userId) throw new GraphQLError('Not authenticated')
|
||||||
phone: profile.phone,
|
const profile = await getOrCreateProfile(ctx.userId)
|
||||||
avatarId: profile.avatarId,
|
if (!(await isAdminUser(profile.userId))) return []
|
||||||
activeTeamId: profile.activeTeam?.uuid ?? null,
|
const members = await prisma.teamMember.findMany({
|
||||||
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,
|
where: profile.activeTeamId === null ? {} : { teamId: profile.activeTeamId },
|
||||||
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() })),
|
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) => {
|
getTeam: async (_: unknown, args: { teamId: string }, ctx: AuthContext) => {
|
||||||
@@ -137,6 +284,34 @@ export const userResolvers = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
Mutation: {
|
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) => {
|
createTeam: async (_: unknown, args: { input: { name: string; teamType?: string } }, ctx: AuthContext) => {
|
||||||
if (!ctx.userId) throw new GraphQLError('Not authenticated')
|
if (!ctx.userId) throw new GraphQLError('Not authenticated')
|
||||||
const profile = await getOrCreateProfile(ctx.userId)
|
const profile = await getOrCreateProfile(ctx.userId)
|
||||||
|
|||||||
Reference in New Issue
Block a user