Use Logto claims for teams auth
Some checks failed
Build Docker Image / build (push) Has been cancelled
Some checks failed
Build Docker Image / build (push) Has been cancelled
This commit is contained in:
198
src/auth.ts
198
src/auth.ts
@@ -1,125 +1,149 @@
|
|||||||
import { createRemoteJWKSet, jwtVerify, SignJWT, decodeJwt, 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'
|
import { prisma } from "./db.js";
|
||||||
|
|
||||||
const LOGTO_JWKS_URL = process.env.LOGTO_JWKS_URL || 'https://auth.optovia.ru/oidc/jwks'
|
const LOGTO_JWKS_URL =
|
||||||
const LOGTO_ISSUER = process.env.LOGTO_ISSUER || 'https://auth.optovia.ru/oidc'
|
process.env.LOGTO_JWKS_URL || "https://auth.optovia.ru/oidc/jwks";
|
||||||
const LOGTO_TEAMS_AUDIENCE = process.env.LOGTO_TEAMS_AUDIENCE || 'https://teams.optovia.ru'
|
const LOGTO_ISSUER = process.env.LOGTO_ISSUER || "https://auth.optovia.ru/oidc";
|
||||||
const APP_JWT_ISSUER = 'optovia:teams'
|
const LOGTO_TEAMS_AUDIENCE =
|
||||||
const APP_JWT_AUDIENCES = ['https://teams.optovia.ru', 'https://orders.optovia.ru', 'https://logistics.optovia.ru']
|
process.env.LOGTO_TEAMS_AUDIENCE || "https://teams.optovia.ru";
|
||||||
|
|
||||||
const jwks = createRemoteJWKSet(new URL(LOGTO_JWKS_URL))
|
const jwks = createRemoteJWKSet(new URL(LOGTO_JWKS_URL));
|
||||||
|
|
||||||
export interface AuthContext {
|
export interface AuthContext {
|
||||||
userId?: string
|
userId?: string;
|
||||||
teamUuid?: string
|
teamUuid?: string;
|
||||||
sessionToken?: string
|
sessionToken?: string;
|
||||||
scopes: string[]
|
scopes: string[];
|
||||||
isM2M?: boolean
|
isM2M?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SESSION_TOKEN_PREFIX = 'optovia-session:'
|
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 "))
|
||||||
const token = auth.slice(7)
|
throw new GraphQLError("Missing Bearer token", {
|
||||||
if (!token || token === 'undefined') throw new GraphQLError('Empty Bearer token', { extensions: { code: 'UNAUTHENTICATED' } })
|
extensions: { code: "UNAUTHENTICATED" },
|
||||||
return token
|
});
|
||||||
|
const token = auth.slice(7);
|
||||||
|
if (!token || token === "undefined")
|
||||||
|
throw new GraphQLError("Empty Bearer token", {
|
||||||
|
extensions: { code: "UNAUTHENTICATED" },
|
||||||
|
});
|
||||||
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
function optionalBearerToken(req: Request): string | null {
|
function optionalBearerToken(req: Request): string | null {
|
||||||
const auth = req.headers.authorization || ''
|
const auth = req.headers.authorization || "";
|
||||||
if (!auth.startsWith('Bearer ')) return null
|
if (!auth.startsWith("Bearer ")) return null;
|
||||||
const token = auth.slice(7)
|
const token = auth.slice(7);
|
||||||
if (!token || token === 'undefined') return null
|
if (!token || token === "undefined") return null;
|
||||||
return token
|
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 [];
|
||||||
if (typeof scope === 'string') return scope.split(' ')
|
if (typeof scope === "string") return scope.split(" ");
|
||||||
return []
|
if (Array.isArray(scope)) return scope as string[];
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function publicContext(): Promise<AuthContext> { return { scopes: [] } }
|
export async function publicContext(): Promise<AuthContext> {
|
||||||
|
return { scopes: [] };
|
||||||
function appJwtSecret(): Uint8Array {
|
|
||||||
const secret = process.env.APP_JWT_SECRET
|
|
||||||
if (!secret) throw new GraphQLError('APP_JWT_SECRET is required', { extensions: { code: 'INTERNAL_SERVER_ERROR' } })
|
|
||||||
return new TextEncoder().encode(secret)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function issueAppJwt(input: { userId: string; teamUuid?: string | null; isManager: boolean }): Promise<string> {
|
function claimList(payload: JWTPayload, key: string): string[] {
|
||||||
const scopes = ['teams:user']
|
const value = (payload as Record<string, unknown>)[key];
|
||||||
if (input.isManager) scopes.push('manager')
|
if (typeof value === "string") return value.split(" ");
|
||||||
return new SignJWT({
|
if (Array.isArray(value))
|
||||||
scope: scopes.join(' '),
|
return value.filter((item): item is string => typeof item === "string");
|
||||||
roles: input.isManager ? ['manager'] : [],
|
return [];
|
||||||
team_uuid: input.teamUuid ?? undefined,
|
|
||||||
})
|
|
||||||
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
|
|
||||||
.setIssuer(APP_JWT_ISSUER)
|
|
||||||
.setAudience(APP_JWT_AUDIENCES)
|
|
||||||
.setSubject(input.userId)
|
|
||||||
.setIssuedAt()
|
|
||||||
.setExpirationTime(`${Number.parseInt(process.env.LOGIN_SESSION_TTL_DAYS || '30', 10)}d`)
|
|
||||||
.sign(appJwtSecret())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verifyAppJwt(token: string, audience: string = LOGTO_TEAMS_AUDIENCE): Promise<AuthContext> {
|
function hasManagerClaim(payload: JWTPayload): boolean {
|
||||||
const { payload } = await jwtVerify(token, appJwtSecret(), { issuer: APP_JWT_ISSUER, audience })
|
const scopes = scopesFromPayload(payload);
|
||||||
const scopes = scopesFromPayload(payload)
|
return (
|
||||||
if (!payload.sub) {
|
scopes.includes("manager") ||
|
||||||
throw new GraphQLError('Unauthorized', { extensions: { code: 'UNAUTHENTICATED' } })
|
claimList(payload, "roles").includes("manager") ||
|
||||||
}
|
claimList(payload, "permissions").includes("manager")
|
||||||
return {
|
);
|
||||||
userId: payload.sub,
|
|
||||||
teamUuid: (payload as Record<string, unknown>).team_uuid as string | undefined,
|
|
||||||
scopes,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function userContext(req: Request): Promise<AuthContext> {
|
export async function userContext(req: Request): Promise<AuthContext> {
|
||||||
const token = optionalBearerToken(req)
|
const token = optionalBearerToken(req);
|
||||||
if (token === null) return { scopes: [] }
|
if (token === null) return { scopes: [] };
|
||||||
if (token.startsWith(SESSION_TOKEN_PREFIX)) {
|
if (token.startsWith(SESSION_TOKEN_PREFIX)) {
|
||||||
const session = await prisma.authSession.findUnique({ where: { token }, include: { user: true } })
|
const session = await prisma.authSession.findUnique({
|
||||||
if (session === null || session.revokedAt !== null || session.expiresAt <= new Date()) {
|
where: { token },
|
||||||
throw new GraphQLError('Session expired', { extensions: { code: 'UNAUTHENTICATED' } })
|
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'] }
|
return {
|
||||||
|
userId: session.user.username,
|
||||||
|
sessionToken: token,
|
||||||
|
scopes: ["teams:user"],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const unverifiedPayload = decodeJwt(token)
|
const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER });
|
||||||
if (unverifiedPayload.iss === APP_JWT_ISSUER) return verifyAppJwt(token)
|
return { userId: payload.sub, scopes: scopesFromPayload(payload) };
|
||||||
const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER })
|
|
||||||
return { userId: payload.sub, scopes: [] }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function managerContext(req: Request): Promise<AuthContext> {
|
export async function managerContext(req: Request): Promise<AuthContext> {
|
||||||
const token = optionalBearerToken(req)
|
const token = getBearerToken(req);
|
||||||
if (token === null) return { scopes: [] }
|
const { payload } = await jwtVerify(token, jwks, {
|
||||||
const context = await verifyAppJwt(token)
|
issuer: LOGTO_ISSUER,
|
||||||
if (!context.scopes.includes('manager')) {
|
audience: LOGTO_TEAMS_AUDIENCE,
|
||||||
throw new GraphQLError('Manager access required', { extensions: { code: 'FORBIDDEN' } })
|
});
|
||||||
|
if (!payload.sub || !hasManagerClaim(payload)) {
|
||||||
|
throw new GraphQLError("Manager access required", {
|
||||||
|
extensions: { code: "FORBIDDEN" },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return context
|
return {
|
||||||
|
userId: payload.sub,
|
||||||
|
teamUuid: (payload as Record<string, unknown>).team_uuid as
|
||||||
|
| string
|
||||||
|
| undefined,
|
||||||
|
scopes: [...new Set([...scopesFromPayload(payload), "manager"])],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function teamContext(req: Request): Promise<AuthContext> {
|
export async function teamContext(req: Request): Promise<AuthContext> {
|
||||||
const token = getBearerToken(req)
|
const token = getBearerToken(req);
|
||||||
const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER, audience: LOGTO_TEAMS_AUDIENCE })
|
const { payload } = await jwtVerify(token, jwks, {
|
||||||
const teamUuid = (payload as Record<string, unknown>).team_uuid as string | undefined
|
issuer: LOGTO_ISSUER,
|
||||||
const scopes = scopesFromPayload(payload)
|
audience: LOGTO_TEAMS_AUDIENCE,
|
||||||
if (!teamUuid || !scopes.includes('teams:member')) throw new GraphQLError('Unauthorized', { extensions: { code: 'UNAUTHENTICATED' } })
|
});
|
||||||
return { userId: payload.sub, teamUuid, scopes }
|
const teamUuid = (payload as Record<string, unknown>).team_uuid as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
|
const scopes = scopesFromPayload(payload);
|
||||||
|
if (!teamUuid || !scopes.includes("teams:member"))
|
||||||
|
throw new GraphQLError("Unauthorized", {
|
||||||
|
extensions: { code: "UNAUTHENTICATED" },
|
||||||
|
});
|
||||||
|
return { userId: payload.sub, teamUuid, scopes };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function m2mContext(): Promise<AuthContext> { return { scopes: [], isM2M: true } }
|
export async function m2mContext(): Promise<AuthContext> {
|
||||||
|
return { scopes: [], isM2M: true };
|
||||||
|
}
|
||||||
|
|
||||||
export function requireScopes(ctx: AuthContext, ...required: string[]): void {
|
export function requireScopes(ctx: AuthContext, ...required: string[]): void {
|
||||||
const missing = required.filter(s => !ctx.scopes.includes(s))
|
const missing = required.filter((s) => !ctx.scopes.includes(s));
|
||||||
if (missing.length > 0) throw new GraphQLError(`Missing required scopes: ${missing.join(', ')}`, { extensions: { code: 'FORBIDDEN' } })
|
if (missing.length > 0)
|
||||||
|
throw new GraphQLError(`Missing required scopes: ${missing.join(", ")}`, {
|
||||||
|
extensions: { code: "FORBIDDEN" },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { GraphQLError } from 'graphql'
|
import { GraphQLError } from "graphql";
|
||||||
import { prisma } from '../db.js'
|
import { prisma } from "../db.js";
|
||||||
import { issueAppJwt, type AuthContext } from '../auth.js'
|
import { 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 {
|
||||||
@@ -28,29 +24,6 @@ export const userTypeDefs = `#graphql
|
|||||||
teams: [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 {
|
type TeamMemberInfo {
|
||||||
uuid: String!
|
uuid: String!
|
||||||
role: String!
|
role: String!
|
||||||
@@ -106,201 +79,229 @@ export const userTypeDefs = `#graphql
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
requestLoginOtp(input: RequestLoginOtpInput!): AuthChallenge!
|
|
||||||
verifyLoginOtp(input: VerifyLoginOtpInput!): AuthPayload!
|
|
||||||
logout: Boolean!
|
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
|
||||||
}
|
}
|
||||||
`
|
`;
|
||||||
|
|
||||||
async function getOrCreateProfile(logtoId: string) {
|
async function getOrCreateProfile(logtoId: string) {
|
||||||
let profile = await prisma.userProfile.findUnique({ where: { logtoId: logtoId }, include: { user: true, activeTeam: true } })
|
let profile = await prisma.userProfile.findUnique({
|
||||||
|
where: { logtoId: logtoId },
|
||||||
|
include: { user: true, activeTeam: true },
|
||||||
|
});
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
const user = await prisma.user.create({ data: { username: logtoId, firstName: '', lastName: '' } })
|
const user = await prisma.user.create({
|
||||||
|
data: { username: logtoId, firstName: "", lastName: "" },
|
||||||
|
});
|
||||||
profile = await prisma.userProfile.create({
|
profile = await prisma.userProfile.create({
|
||||||
data: { userId: user.id, logtoId: logtoId },
|
data: { userId: user.id, logtoId: logtoId },
|
||||||
include: { user: true, activeTeam: true },
|
include: { user: true, activeTeam: true },
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
return profile
|
return profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePhone(phone: string): string {
|
function displayName(
|
||||||
const normalized = phone.replace(/[^\d+]/g, '')
|
firstName: string,
|
||||||
if (normalized.length < 5) throw new GraphQLError('Invalid phone')
|
lastName: string,
|
||||||
return normalized
|
phone: string,
|
||||||
}
|
): string {
|
||||||
|
const name = `${firstName} ${lastName}`.trim();
|
||||||
function displayName(firstName: string, lastName: string, phone: string): string {
|
return name.length > 0 ? name : phone;
|
||||||
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> {
|
async function isManagerUser(userId: number): Promise<boolean> {
|
||||||
const user = await prisma.user.findUnique({ where: { id: userId } })
|
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
if (user === null) return false
|
if (user === null) return false;
|
||||||
if (user.isStaff || user.isSuperuser) return true
|
if (user.isStaff || user.isSuperuser) return true;
|
||||||
const managerMembership = await prisma.teamMember.findFirst({
|
const managerMembership = await prisma.teamMember.findFirst({
|
||||||
where: { userId, role: { in: ['OWNER', 'MANAGER', 'ADMIN'] } },
|
where: { userId, role: { in: ["OWNER", "MANAGER", "ADMIN"] } },
|
||||||
})
|
});
|
||||||
return managerMembership !== null
|
return managerMembership !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mapProfileUser(profile: Awaited<ReturnType<typeof getOrCreateProfile>>) {
|
async function mapProfileUser(
|
||||||
const memberships = await prisma.teamMember.findMany({ where: { userId: profile.userId }, include: { team: true } })
|
profile: Awaited<ReturnType<typeof getOrCreateProfile>>,
|
||||||
const isManager = await isManagerUser(profile.userId)
|
) {
|
||||||
|
const memberships = await prisma.teamMember.findMany({
|
||||||
|
where: { userId: profile.userId },
|
||||||
|
include: { team: true },
|
||||||
|
});
|
||||||
|
const isManager = await isManagerUser(profile.userId);
|
||||||
return {
|
return {
|
||||||
id: profile.logtoId,
|
id: profile.logtoId,
|
||||||
firstName: profile.user.firstName,
|
firstName: profile.user.firstName,
|
||||||
lastName: profile.user.lastName,
|
lastName: profile.user.lastName,
|
||||||
displayName: displayName(profile.user.firstName, profile.user.lastName, profile.phone),
|
displayName: displayName(
|
||||||
|
profile.user.firstName,
|
||||||
|
profile.user.lastName,
|
||||||
|
profile.phone,
|
||||||
|
),
|
||||||
phone: profile.phone,
|
phone: profile.phone,
|
||||||
avatarId: profile.avatarId,
|
avatarId: profile.avatarId,
|
||||||
isManager,
|
isManager,
|
||||||
activeTeamId: profile.activeTeam?.uuid ?? null,
|
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,
|
activeTeam: profile.activeTeam
|
||||||
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() })),
|
? {
|
||||||
}
|
id: profile.activeTeam.uuid,
|
||||||
}
|
name: profile.activeTeam.name,
|
||||||
|
teamType: profile.activeTeam.teamType,
|
||||||
async function verifyPhoneLogin(phone: string, code: string) {
|
logtoOrgId: profile.activeTeam.logtoOrgId,
|
||||||
const normalizedPhone = normalizePhone(phone)
|
createdAt: profile.activeTeam.createdAt.toISOString(),
|
||||||
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),
|
|
||||||
}
|
}
|
||||||
|
: 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(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
return mapProfileUser(profile)
|
return mapProfileUser(profile);
|
||||||
},
|
},
|
||||||
|
|
||||||
getTeam: async (_: unknown, args: { teamId: string }, ctx: AuthContext) => {
|
getTeam: async (_: unknown, args: { teamId: string }, ctx: AuthContext) => {
|
||||||
if (!ctx.userId) throw new GraphQLError('Not authenticated')
|
if (!ctx.userId) throw new GraphQLError("Not authenticated");
|
||||||
const team = await prisma.team.findUnique({
|
const team = await prisma.team.findUnique({
|
||||||
where: { uuid: args.teamId },
|
where: { uuid: args.teamId },
|
||||||
include: {
|
include: {
|
||||||
members: { include: { user: true } },
|
members: { include: { user: true } },
|
||||||
invitations: true,
|
invitations: true,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
if (!team) return null
|
if (!team) return null;
|
||||||
return {
|
return {
|
||||||
uuid: team.uuid,
|
uuid: team.uuid,
|
||||||
name: team.name,
|
name: team.name,
|
||||||
members: team.members.map(m => ({ uuid: m.uuid, role: m.role, joinedAt: m.joinedAt.toISOString() })),
|
members: team.members.map((m) => ({
|
||||||
invitations: team.invitations.map(i => ({
|
uuid: m.uuid,
|
||||||
uuid: i.uuid, email: i.email, role: i.role, status: i.status,
|
role: m.role,
|
||||||
invitedBy: i.invitedBy, expiresAt: i.expiresAt?.toISOString() ?? null,
|
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(),
|
createdAt: i.createdAt.toISOString(),
|
||||||
})),
|
})),
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
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)
|
|
||||||
},
|
|
||||||
|
|
||||||
logout: async (_: unknown, __: unknown, ctx: AuthContext) => {
|
logout: async (_: unknown, __: unknown, ctx: AuthContext) => {
|
||||||
if (ctx.sessionToken !== undefined) {
|
if (ctx.sessionToken !== undefined) {
|
||||||
await prisma.authSession.updateMany({ where: { token: ctx.sessionToken, revokedAt: null }, data: { revokedAt: new Date() } })
|
await prisma.authSession.updateMany({
|
||||||
|
where: { token: ctx.sessionToken, revokedAt: null },
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return true
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
createTeam: async (_: unknown, args: { input: { name: string; teamType?: string } }, ctx: AuthContext) => {
|
createTeam: async (
|
||||||
if (!ctx.userId) throw new GraphQLError('Not authenticated')
|
_: unknown,
|
||||||
const profile = await getOrCreateProfile(ctx.userId)
|
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({
|
const team = await prisma.team.create({
|
||||||
data: { name: args.input.name, teamType: args.input.teamType || 'BUYER', ownerId: profile.userId },
|
data: {
|
||||||
})
|
name: args.input.name,
|
||||||
await prisma.teamMember.create({ data: { teamId: team.id, userId: profile.userId, role: 'OWNER' } })
|
teamType: args.input.teamType || "BUYER",
|
||||||
await prisma.userProfile.update({ where: { id: profile.id }, data: { activeTeamId: team.id } })
|
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 {
|
return {
|
||||||
team: { id: team.uuid, name: team.name, teamType: team.teamType, logtoOrgId: team.logtoOrgId, createdAt: team.createdAt.toISOString() },
|
team: {
|
||||||
|
id: team.uuid,
|
||||||
|
name: team.name,
|
||||||
|
teamType: team.teamType,
|
||||||
|
logtoOrgId: team.logtoOrgId,
|
||||||
|
createdAt: team.createdAt.toISOString(),
|
||||||
|
},
|
||||||
success: true,
|
success: true,
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
updateUser: async (_: unknown, args: { userId: string; input: { firstName?: string; lastName?: string; phone?: string; avatarId?: string } }, ctx: AuthContext) => {
|
updateUser: async (
|
||||||
if (!ctx.userId) throw new GraphQLError('Not authenticated')
|
_: unknown,
|
||||||
const profile = await getOrCreateProfile(ctx.userId)
|
args: {
|
||||||
const userUpdate: Record<string, unknown> = {}
|
userId: string;
|
||||||
if (args.input.firstName !== undefined) userUpdate.firstName = args.input.firstName
|
input: {
|
||||||
if (args.input.lastName !== undefined) userUpdate.lastName = args.input.lastName
|
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) {
|
if (Object.keys(userUpdate).length > 0) {
|
||||||
await prisma.user.update({ where: { id: profile.userId }, data: userUpdate })
|
await prisma.user.update({
|
||||||
|
where: { id: profile.userId },
|
||||||
|
data: userUpdate,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const profileUpdate: Record<string, unknown> = {}
|
const profileUpdate: Record<string, unknown> = {};
|
||||||
if (args.input.phone !== undefined) profileUpdate.phone = args.input.phone
|
if (args.input.phone !== undefined)
|
||||||
if (args.input.avatarId !== undefined) profileUpdate.avatarId = args.input.avatarId
|
profileUpdate.phone = args.input.phone;
|
||||||
|
if (args.input.avatarId !== undefined)
|
||||||
|
profileUpdate.avatarId = args.input.avatarId;
|
||||||
if (Object.keys(profileUpdate).length > 0) {
|
if (Object.keys(profileUpdate).length > 0) {
|
||||||
await prisma.userProfile.update({ where: { id: profile.id }, data: profileUpdate })
|
await prisma.userProfile.update({
|
||||||
|
where: { id: profile.id },
|
||||||
|
data: profileUpdate,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return { user: { id: ctx.userId }, success: true }
|
return { user: { id: ctx.userId }, success: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
switchTeam: async (_: unknown, args: { teamId: string }, ctx: AuthContext) => {
|
switchTeam: async (
|
||||||
if (!ctx.userId) throw new GraphQLError('Not authenticated')
|
_: unknown,
|
||||||
const profile = await getOrCreateProfile(ctx.userId)
|
args: { teamId: string },
|
||||||
const team = await prisma.team.findUnique({ where: { uuid: args.teamId } })
|
ctx: AuthContext,
|
||||||
if (!team) throw new GraphQLError('Team not found')
|
) => {
|
||||||
await prisma.userProfile.update({ where: { id: profile.id }, data: { activeTeamId: team.id } })
|
if (!ctx.userId) throw new GraphQLError("Not authenticated");
|
||||||
return { success: true }
|
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 };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user