Compare commits

...

6 Commits

Author SHA1 Message Date
Ruslan Bakiev
7fc16bb333 Use Fastify Mercurius and GraphQL contracts
Some checks are pending
Build Docker Image / build (push) Waiting to run
2026-06-01 00:25:27 +05:00
Ruslan Bakiev
b076343c57 Use Logto claims for teams auth
Some checks failed
Build Docker Image / build (push) Has been cancelled
2026-05-31 22:26:58 +05:00
Ruslan Bakiev
1a3f72205f Use shared app JWT for manager access 2026-05-31 22:00:52 +05:00
Ruslan Bakiev
bca8c0e782 Add manager GraphQL endpoint 2026-05-31 21:52:42 +05:00
Ruslan Bakiev
0473291940 Remove admin role alias from Teams auth 2026-05-31 21:42:39 +05:00
Ruslan Bakiev
71bb2e951b Expose manager session flag 2026-05-31 21:36:19 +05:00
8 changed files with 1619 additions and 1819 deletions

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "graphql-contracts"]
path = graphql-contracts
url = git@gitea.dsrptlab.com:optovia/teams-graphql-contracts.git

1
graphql-contracts Submodule

Submodule graphql-contracts added at c632d41e35

2664
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,18 +9,16 @@
"start": "prisma migrate deploy && node dist/index.js" "start": "prisma migrate deploy && node dist/index.js"
}, },
"dependencies": { "dependencies": {
"@apollo/server": "^4.11.3", "@fastify/cors": "^11.2.0",
"@prisma/client": "^6.5.0", "@prisma/client": "^6.5.0",
"@sentry/node": "^9.5.0", "@sentry/node": "^9.5.0",
"cors": "^2.8.5", "fastify": "^5.8.5",
"express": "^4.21.2",
"graphql": "^16.10.0", "graphql": "^16.10.0",
"graphql-tag": "^2.12.6", "graphql-tag": "^2.12.6",
"jose": "^6.0.11" "jose": "^6.0.11",
"mercurius": "^16.9.0"
}, },
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.13.0", "@types/node": "^22.13.0",
"prisma": "^6.5.0", "prisma": "^6.5.0",
"tsx": "^4.19.0", "tsx": "^4.19.0",

View File

@@ -1,75 +1,151 @@
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 { FastifyRequest } from "fastify";
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 LOGTO_TEAMS_AUDIENCE =
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: FastifyRequest): 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: FastifyRequest): 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: [] };
}
export async function userContext(req: Request): Promise<AuthContext> { function claimList(payload: JWTPayload, key: string): string[] {
const token = optionalBearerToken(req) const value = (payload as Record<string, unknown>)[key];
if (token === null) return { scopes: [] } if (typeof value === "string") return value.split(" ");
if (Array.isArray(value))
return value.filter((item): item is string => typeof item === "string");
return [];
}
function hasManagerClaim(payload: JWTPayload): boolean {
const scopes = scopesFromPayload(payload);
return (
scopes.includes("manager") ||
claimList(payload, "roles").includes("manager") ||
claimList(payload, "permissions").includes("manager")
);
}
export async function userContext(req: FastifyRequest): Promise<AuthContext> {
const token = optionalBearerToken(req);
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 { 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: scopesFromPayload(payload) };
} }
export async function teamContext(req: Request): Promise<AuthContext> { export async function managerContext(
const token = getBearerToken(req) req: FastifyRequest,
const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER, audience: LOGTO_TEAMS_AUDIENCE }) ): Promise<AuthContext> {
const teamUuid = (payload as Record<string, unknown>).team_uuid as string | undefined const token = getBearerToken(req);
const scopes = scopesFromPayload(payload) const { payload } = await jwtVerify(token, jwks, {
if (!teamUuid || !scopes.includes('teams:member')) throw new GraphQLError('Unauthorized', { extensions: { code: 'UNAUTHENTICATED' } }) issuer: LOGTO_ISSUER,
return { userId: payload.sub, teamUuid, scopes } audience: LOGTO_TEAMS_AUDIENCE,
});
if (!payload.sub || !hasManagerClaim(payload)) {
throw new GraphQLError("Manager access required", {
extensions: { code: "FORBIDDEN" },
});
}
return {
userId: payload.sub,
teamUuid: (payload as Record<string, unknown>).team_uuid as
| string
| undefined,
scopes: [...new Set([...scopesFromPayload(payload), "manager"])],
};
} }
export async function m2mContext(): Promise<AuthContext> { return { scopes: [], isM2M: true } } export async function teamContext(req: FastifyRequest): Promise<AuthContext> {
const token = getBearerToken(req);
const { payload } = await jwtVerify(token, jwks, {
issuer: LOGTO_ISSUER,
audience: LOGTO_TEAMS_AUDIENCE,
});
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 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" },
});
} }

View File

@@ -1,47 +1,115 @@
import express from 'express' import Fastify from "fastify";
import cors from 'cors' import type { FastifyInstance, FastifyRequest } from "fastify";
import { ApolloServer } from '@apollo/server' import cors from "@fastify/cors";
import { expressMiddleware } from '@apollo/server/express4' import mercurius from "mercurius";
import * as Sentry from '@sentry/node' import * as Sentry from "@sentry/node";
import { publicTypeDefs, publicResolvers } from './schemas/public.js' import { publicTypeDefs, publicResolvers } from "./schemas/public.js";
import { userTypeDefs, userResolvers } from './schemas/user.js' import { userTypeDefs, userResolvers } from "./schemas/user.js";
import { teamTypeDefs, teamResolvers } from './schemas/team.js' import { teamTypeDefs, teamResolvers } from "./schemas/team.js";
import { m2mTypeDefs, m2mResolvers } from './schemas/m2m.js' import { managerTypeDefs, managerResolvers } from "./schemas/manager.js";
import { publicContext, userContext, teamContext, m2mContext, type AuthContext } from './auth.js' import { m2mTypeDefs, m2mResolvers } from "./schemas/m2m.js";
import {
publicContext,
userContext,
teamContext,
managerContext,
m2mContext,
} from "./auth.js";
const PORT = parseInt(process.env.PORT || '8000', 10) const PORT = Number.parseInt(process.env.PORT || "8000", 10);
const SENTRY_DSN = process.env.SENTRY_DSN || '' const SENTRY_DSN = process.env.SENTRY_DSN || "";
if (SENTRY_DSN) { if (SENTRY_DSN) {
Sentry.init({ Sentry.init({
dsn: SENTRY_DSN, dsn: SENTRY_DSN,
tracesSampleRate: 0.01, tracesSampleRate: 0.01,
release: process.env.RELEASE_VERSION || '1.0.0', release: process.env.RELEASE_VERSION || "1.0.0",
environment: process.env.ENVIRONMENT || 'production', environment: process.env.ENVIRONMENT || "production",
}) });
} }
const app = express() const app = Fastify();
app.use(cors({ origin: ['https://optovia.ru'], credentials: true })) await app.register(cors, { origin: ["https://optovia.ru"], credentials: true });
const publicServer = new ApolloServer<AuthContext>({ typeDefs: publicTypeDefs, resolvers: publicResolvers, introspection: true }) type GraphqlBody = {
const userServer = new ApolloServer<AuthContext>({ typeDefs: userTypeDefs, resolvers: userResolvers, introspection: true }) query?: string;
const teamServer = new ApolloServer<AuthContext>({ typeDefs: teamTypeDefs, resolvers: teamResolvers, introspection: true }) variables?: Record<string, unknown>;
const m2mServer = new ApolloServer<AuthContext>({ typeDefs: m2mTypeDefs, resolvers: m2mResolvers, introspection: true }) operationName?: string;
};
await Promise.all([publicServer.start(), userServer.start(), teamServer.start(), m2mServer.start()]) async function registerGraphqlEndpoint(
server: FastifyInstance,
path: string,
schema: string,
resolvers: unknown,
context: (request: FastifyRequest) => Promise<unknown> | unknown,
) {
await server.register(
async (route) => {
await route.register(mercurius, {
schema,
resolvers: resolvers as never,
routes: false,
});
route.post("/", async (request, reply) => {
const body = request.body as GraphqlBody;
if (typeof body.query !== "string") {
throw new Error("GraphQL query is required");
}
return reply.graphql(
body.query,
(await context(request)) as Record<string, unknown>,
body.variables,
body.operationName,
);
});
},
{ prefix: path },
);
}
app.use('/graphql/public', express.json(), expressMiddleware(publicServer, { context: async () => publicContext() }) as unknown as express.RequestHandler) await registerGraphqlEndpoint(
app.use('/graphql/user', express.json(), expressMiddleware(userServer, { context: async ({ req }) => userContext(req as unknown as import('express').Request) }) as unknown as express.RequestHandler) app,
app.use('/graphql/team', express.json(), expressMiddleware(teamServer, { context: async ({ req }) => teamContext(req as unknown as import('express').Request) }) as unknown as express.RequestHandler) "/graphql/public",
app.use('/graphql/m2m', express.json(), expressMiddleware(m2mServer, { context: async () => m2mContext() }) as unknown as express.RequestHandler) publicTypeDefs,
publicResolvers,
async () => publicContext(),
);
await registerGraphqlEndpoint(
app,
"/graphql/user",
userTypeDefs,
userResolvers,
userContext,
);
await registerGraphqlEndpoint(
app,
"/graphql/team",
teamTypeDefs,
teamResolvers,
teamContext,
);
await registerGraphqlEndpoint(
app,
"/graphql/manager",
managerTypeDefs,
managerResolvers,
managerContext,
);
await registerGraphqlEndpoint(
app,
"/graphql/m2m",
m2mTypeDefs,
m2mResolvers,
async () => m2mContext(),
);
app.get('/health', (_, res) => { res.json({ status: 'ok' }) }) app.get("/health", async () => ({ status: "ok" }));
app.listen(PORT, '0.0.0.0', () => { await app.listen({ port: PORT, host: "0.0.0.0" });
console.log(`Teams server ready on port ${PORT}`) console.log(`Teams server ready on port ${PORT}`);
console.log(` /graphql/public - public`) console.log(` /graphql/public - public`);
console.log(` /graphql/user - id token auth`) console.log(` /graphql/user - id token auth`);
console.log(` /graphql/team - team access token auth`) console.log(` /graphql/team - team access token auth`);
console.log(` /graphql/m2m - internal services (no auth)`) console.log(` /graphql/manager - manager JWT auth`);
}) console.log(` /graphql/m2m - internal services (no auth)`);

74
src/schemas/manager.ts Normal file
View File

@@ -0,0 +1,74 @@
import { GraphQLError } from 'graphql'
import { prisma } from '../db.js'
import { requireScopes, type AuthContext } from '../auth.js'
export const managerTypeDefs = `#graphql
type ManagerUser {
id: ID!
accountId: String
displayName: String
phone: String!
companyName: String
telegramGroupTitle: String
telegramNotificationsEnabled: Boolean!
}
type Query {
managerUsers: [ManagerUser!]!
}
`
function displayName(firstName: string, lastName: string, phone: string): string {
const name = `${firstName} ${lastName}`.trim()
return name.length > 0 ? name : phone
}
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 requireManagerProfile(ctx: AuthContext) {
requireScopes(ctx, 'manager')
if (!ctx.userId) throw new GraphQLError('Not authenticated')
const profile = await prisma.userProfile.findUnique({
where: { logtoId: ctx.userId },
include: { user: true, activeTeam: true },
})
if (profile === null || !(await isManagerUser(profile.userId))) {
throw new GraphQLError('Manager access required', { extensions: { code: 'FORBIDDEN' } })
}
return profile
}
export const managerResolvers = {
Query: {
managerUsers: async (_: unknown, __: unknown, ctx: AuthContext) => {
const profile = await requireManagerProfile(ctx)
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,
}
})
},
},
}

View File

@@ -1,11 +1,6 @@
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 {
@@ -23,50 +18,12 @@ export const userTypeDefs = `#graphql
displayName: String displayName: String
phone: String phone: String
avatarId: String avatarId: String
isAdmin: Boolean isManager: 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!
@@ -118,240 +75,233 @@ 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! 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();
return name.length > 0 ? name : phone;
} }
function displayName(firstName: string, lastName: string, phone: string): string { async function isManagerUser(userId: number): Promise<boolean> {
const name = `${firstName} ${lastName}`.trim() const user = await prisma.user.findUnique({ where: { id: userId } });
return name.length > 0 ? name : phone if (user === null) return false;
} if (user.isStaff || user.isSuperuser) return true;
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({ 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 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,
isAdmin: await isAdminUser(profile.userId), 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 issueSession(userId: number) { logtoOrgId: profile.activeTeam.logtoOrgId,
const token = `${SESSION_TOKEN_PREFIX}${randomBytes(32).toString('hex')}` createdAt: profile.activeTeam.createdAt.toISOString(),
const expiresAt = new Date(Date.now() + SESSION_TTL_DAYS * 24 * 60 * 60 * 1000) }
await prisma.authSession.create({ data: { token, userId, expiresAt } }) : null,
return { token, expiresAt } teams: memberships.map((m) => ({
} id: m.team.uuid,
name: m.team.name,
async function verifyPhoneLogin(phone: string, code: string) { teamType: m.team.teamType,
const normalizedPhone = normalizePhone(phone) logtoOrgId: m.team.logtoOrgId,
const challenge = await prisma.loginChallenge.findFirst({ createdAt: m.team.createdAt.toISOString(),
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);
return mapProfileUser(profile) 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) => { 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)
},
adminTotpLogin: 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 };
}, },
}, },
} };