mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-25 02:13:00 +08:00
feat(server): auth server (#2773)
This commit is contained in:
@@ -0,0 +1,77 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `avatar_url` on the `users` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `fulfilled` on the `users` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `token_nonce` on the `users` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the `connected_accounts` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "connected_accounts" DROP CONSTRAINT "connected_accounts_user_id_fkey";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" DROP COLUMN "avatar_url",
|
||||||
|
DROP COLUMN "fulfilled",
|
||||||
|
DROP COLUMN "token_nonce",
|
||||||
|
ADD COLUMN "email_verified" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "image" VARCHAR,
|
||||||
|
ALTER COLUMN "name" SET DATA TYPE TEXT,
|
||||||
|
ALTER COLUMN "email" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "email" SET DATA TYPE TEXT;
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "connected_accounts";
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "accounts" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"provider" TEXT NOT NULL,
|
||||||
|
"provider_account_id" TEXT NOT NULL,
|
||||||
|
"refresh_token" TEXT,
|
||||||
|
"access_token" TEXT,
|
||||||
|
"expires_at" INTEGER,
|
||||||
|
"token_type" TEXT,
|
||||||
|
"scope" TEXT,
|
||||||
|
"id_token" TEXT,
|
||||||
|
"session_state" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "accounts_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "sessions" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"session_token" TEXT NOT NULL,
|
||||||
|
"user_id" TEXT NOT NULL,
|
||||||
|
"expires" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "verificationtokens" (
|
||||||
|
"identifier" TEXT NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"expires" TIMESTAMP(3) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "accounts_provider_provider_account_id_key" ON "accounts"("provider", "provider_account_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "sessions_session_token_key" ON "sessions"("session_token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "verificationtokens_token_key" ON "verificationtokens"("token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "verificationtokens_identifier_token_key" ON "verificationtokens"("identifier", "token");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -15,25 +15,32 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/server": "^4.7.4",
|
"@apollo/server": "^4.7.4",
|
||||||
"@nestjs/apollo": "^11.0.6",
|
"@auth/prisma-adapter": "^1.0.0",
|
||||||
"@nestjs/common": "^10.0.0",
|
"@aws-sdk/client-s3": "^3.354.0",
|
||||||
"@nestjs/core": "^10.0.0",
|
"@nestjs/apollo": "^12.0.1",
|
||||||
"@nestjs/graphql": "^11.0.6",
|
"@nestjs/common": "^10.0.2",
|
||||||
"@nestjs/platform-express": "^10.0.0",
|
"@nestjs/core": "^10.0.2",
|
||||||
"@node-rs/bcrypt": "^1.7.1",
|
"@nestjs/graphql": "^12.0.1",
|
||||||
|
"@nestjs/platform-express": "^10.0.2",
|
||||||
|
"@node-rs/argon2": "^1.5.0",
|
||||||
|
"@node-rs/crc32": "^1.7.0",
|
||||||
|
"@node-rs/jsonwebtoken": "^0.2.0",
|
||||||
"@prisma/client": "^4.15.0",
|
"@prisma/client": "^4.15.0",
|
||||||
"dotenv": "^16.1.4",
|
"dotenv": "^16.1.4",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"graphql": "^16.6.0",
|
"graphql": "^16.6.0",
|
||||||
"graphql-type-json": "^0.3.2",
|
"graphql-type-json": "^0.3.2",
|
||||||
"jsonwebtoken": "^9.0.0",
|
"graphql-upload": "^16.0.2",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"prisma": "^4.15.0",
|
"next-auth": "^4.22.1",
|
||||||
|
"parse-duration": "^1.1.0",
|
||||||
|
"prisma": "^4.16.0",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/testing": "^10.0.0",
|
"@napi-rs/image": "^1.6.1",
|
||||||
|
"@nestjs/testing": "^10.0.2",
|
||||||
"@types/express": "^4.17.17",
|
"@types/express": "^4.17.17",
|
||||||
"@types/jsonwebtoken": "^9.0.2",
|
"@types/jsonwebtoken": "^9.0.2",
|
||||||
"@types/lodash-es": "^4.17.7",
|
"@types/lodash-es": "^4.17.7",
|
||||||
@@ -43,8 +50,7 @@
|
|||||||
"nodemon": "^2.0.22",
|
"nodemon": "^2.0.22",
|
||||||
"supertest": "^6.3.3",
|
"supertest": "^6.3.3",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"typescript": "^5.1.3",
|
"typescript": "^5.1.3"
|
||||||
"vitest": "^0.32.0"
|
|
||||||
},
|
},
|
||||||
"nodemonConfig": {
|
"nodemonConfig": {
|
||||||
"exec": "node",
|
"exec": "node",
|
||||||
|
|||||||
@@ -1,29 +1,10 @@
|
|||||||
datasource db {
|
|
||||||
provider = "postgresql"
|
|
||||||
url = env("DATABASE_URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
datasource db {
|
||||||
id String @id @default(uuid()) @db.VarChar
|
provider = "postgresql"
|
||||||
name String @db.VarChar
|
url = env("DATABASE_URL")
|
||||||
email String @unique @db.VarChar
|
|
||||||
tokenNonce Int @default(0) @map("token_nonce") @db.SmallInt
|
|
||||||
avatarUrl String? @map("avatar_url") @db.VarChar
|
|
||||||
/// Available if user signed up through OAuth providers
|
|
||||||
password String? @db.VarChar
|
|
||||||
/// User may created by email collobration invitation before signup.
|
|
||||||
/// We will precreate a user entity in such senarios but leave fulfilled as false until they signed up
|
|
||||||
/// This implementation is convenient for handing unregistered user permissoin
|
|
||||||
fulfilled Boolean @default(true)
|
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
|
||||||
connectedAccounts ConnectedAccount[]
|
|
||||||
workspaces UserWorkspacePermission[]
|
|
||||||
|
|
||||||
@@map("users")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model Workspace {
|
model Workspace {
|
||||||
@@ -35,18 +16,6 @@ model Workspace {
|
|||||||
@@map("workspaces")
|
@@map("workspaces")
|
||||||
}
|
}
|
||||||
|
|
||||||
model ConnectedAccount {
|
|
||||||
id String @id @default(uuid()) @db.VarChar
|
|
||||||
userId String @map("user_id")
|
|
||||||
/// the general provider name, e.g. google, github, facebook
|
|
||||||
provider String @db.VarChar
|
|
||||||
/// the user id provided by OAuth providers, or other user identitive credential like `username` provided by GitHub
|
|
||||||
providerUserId String @unique @map("provider_user_id") @db.VarChar
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@map("connected_accounts")
|
|
||||||
}
|
|
||||||
|
|
||||||
model UserWorkspacePermission {
|
model UserWorkspacePermission {
|
||||||
id String @id @default(uuid()) @db.VarChar
|
id String @id @default(uuid()) @db.VarChar
|
||||||
workspaceId String @map("workspace_id") @db.VarChar
|
workspaceId String @map("workspace_id") @db.VarChar
|
||||||
@@ -56,8 +25,64 @@ model UserWorkspacePermission {
|
|||||||
/// Whether the permission invitation is accepted by the user
|
/// Whether the permission invitation is accepted by the user
|
||||||
accepted Boolean @default(false)
|
accepted Boolean @default(false)
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@map("user_workspace_permissions")
|
@@map("user_workspace_permissions")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(uuid()) @db.VarChar
|
||||||
|
name String
|
||||||
|
email String? @unique
|
||||||
|
emailVerified DateTime? @map("email_verified")
|
||||||
|
// image field is for the next-auth
|
||||||
|
avatarUrl String? @map("image") @db.VarChar
|
||||||
|
accounts Account[]
|
||||||
|
sessions Session[]
|
||||||
|
workspaces UserWorkspacePermission[]
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
/// Not available if user signed up through OAuth providers
|
||||||
|
password String? @db.VarChar
|
||||||
|
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Account {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @map("user_id")
|
||||||
|
type String
|
||||||
|
provider String
|
||||||
|
providerAccountId String @map("provider_account_id")
|
||||||
|
refresh_token String? @db.Text
|
||||||
|
access_token String? @db.Text
|
||||||
|
expires_at Int?
|
||||||
|
token_type String?
|
||||||
|
scope String?
|
||||||
|
id_token String? @db.Text
|
||||||
|
session_state String?
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([provider, providerAccountId])
|
||||||
|
@@map("accounts")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Session {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sessionToken String @unique @map("session_token")
|
||||||
|
userId String @map("user_id")
|
||||||
|
expires DateTime
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("sessions")
|
||||||
|
}
|
||||||
|
|
||||||
|
model VerificationToken {
|
||||||
|
identifier String
|
||||||
|
token String @unique
|
||||||
|
expires DateTime
|
||||||
|
|
||||||
|
@@unique([identifier, token])
|
||||||
|
@@map("verificationtokens")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
import { genSalt } from '@node-rs/bcrypt';
|
|
||||||
|
|
||||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', {
|
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', {
|
||||||
namedCurve: 'prime256v1',
|
namedCurve: 'prime256v1',
|
||||||
publicKeyEncoding: {
|
publicKeyEncoding: {
|
||||||
@@ -14,6 +12,5 @@ const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Salt:\n', await genSalt(10));
|
|
||||||
console.log('ECDSA Public Key:\n', publicKey);
|
console.log('ECDSA Public Key:\n', publicKey);
|
||||||
console.log('ECDSA Private Key:\n', privateKey);
|
console.log('ECDSA Private Key:\n', privateKey);
|
||||||
|
|||||||
@@ -163,6 +163,12 @@ export interface AFFiNEConfig {
|
|||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
config: Record<string, string>;
|
config: Record<string, string>;
|
||||||
|
/**
|
||||||
|
* Only used when `enable` is `false`
|
||||||
|
*/
|
||||||
|
fs: {
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -172,11 +178,16 @@ export interface AFFiNEConfig {
|
|||||||
/**
|
/**
|
||||||
* Application access token expiration time
|
* Application access token expiration time
|
||||||
*/
|
*/
|
||||||
readonly accessTokenExpiresIn: string;
|
readonly accessTokenExpiresIn: number;
|
||||||
/**
|
/**
|
||||||
* Application refresh token expiration time
|
* Application refresh token expiration time
|
||||||
*/
|
*/
|
||||||
readonly refreshTokenExpiresIn: string;
|
readonly refreshTokenExpiresIn: number;
|
||||||
|
/**
|
||||||
|
* Add some leeway (in seconds) to the exp and nbf validation to account for clock skew.
|
||||||
|
* Defaults to 60 if omitted.
|
||||||
|
*/
|
||||||
|
readonly leeway: number;
|
||||||
/**
|
/**
|
||||||
* Application public key
|
* Application public key
|
||||||
*
|
*
|
||||||
@@ -195,6 +206,10 @@ export interface AFFiNEConfig {
|
|||||||
* whether allow user to signup by oauth providers
|
* whether allow user to signup by oauth providers
|
||||||
*/
|
*/
|
||||||
enableOauth: boolean;
|
enableOauth: boolean;
|
||||||
|
/**
|
||||||
|
* NEXTAUTH_SECRET
|
||||||
|
*/
|
||||||
|
nextAuthSecret: string;
|
||||||
/**
|
/**
|
||||||
* all available oauth providers
|
* all available oauth providers
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
/// <reference types="../global.d.ts" />
|
/// <reference types="../global.d.ts" />
|
||||||
|
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import parse from 'parse-duration';
|
||||||
|
|
||||||
import pkg from '../../package.json' assert { type: 'json' };
|
import pkg from '../../package.json' assert { type: 'json' };
|
||||||
import type { AFFiNEConfig } from './def';
|
import type { AFFiNEConfig } from './def';
|
||||||
|
|
||||||
@@ -56,16 +61,23 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => ({
|
|||||||
debug: true,
|
debug: true,
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
accessTokenExpiresIn: '1h',
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
refreshTokenExpiresIn: '7d',
|
accessTokenExpiresIn: parse('1h')! / 1000,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
refreshTokenExpiresIn: parse('7d')! / 1000,
|
||||||
|
leeway: 60,
|
||||||
publicKey: examplePublicKey,
|
publicKey: examplePublicKey,
|
||||||
privateKey: examplePrivateKey,
|
privateKey: examplePrivateKey,
|
||||||
enableSignup: true,
|
enableSignup: true,
|
||||||
enableOauth: false,
|
enableOauth: false,
|
||||||
|
nextAuthSecret: '',
|
||||||
oauthProviders: {},
|
oauthProviders: {},
|
||||||
},
|
},
|
||||||
objectStorage: {
|
objectStorage: {
|
||||||
enable: false,
|
enable: false,
|
||||||
config: {},
|
config: {},
|
||||||
|
fs: {
|
||||||
|
path: join(homedir(), '.affine-storage'),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import { Config } from './config';
|
|||||||
return {
|
return {
|
||||||
...config.graphql,
|
...config.graphql,
|
||||||
path: `${config.path}/graphql`,
|
path: `${config.path}/graphql`,
|
||||||
|
csrfPrevention: {
|
||||||
|
requestHeaders: ['content-type'],
|
||||||
|
},
|
||||||
autoSchemaFile: join(
|
autoSchemaFile: join(
|
||||||
fileURLToPath(import.meta.url),
|
fileURLToPath(import.meta.url),
|
||||||
'..',
|
'..',
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ import './prelude';
|
|||||||
|
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
import { static as staticMiddleware } from 'express';
|
||||||
|
// @ts-expect-error graphql-upload is not typed
|
||||||
|
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
|
||||||
|
|
||||||
import { AppModule } from './app';
|
import { AppModule } from './app';
|
||||||
|
import { Config } from './config';
|
||||||
|
|
||||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||||
cors: {
|
cors: {
|
||||||
@@ -12,14 +16,27 @@ const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
|||||||
? ['https://affine-preview.vercel.app']
|
? ['https://affine-preview.vercel.app']
|
||||||
: ['http://localhost:8080'],
|
: ['http://localhost:8080'],
|
||||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
allowedHeaders: '*',
|
allowedHeaders: ['x-operation-name', 'x-definition-name'],
|
||||||
},
|
},
|
||||||
bodyParser: true,
|
bodyParser: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
graphqlUploadExpress({
|
||||||
|
maxFileSize: 10 * 1024 * 1024,
|
||||||
|
maxFiles: 5,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const host = process.env.HOST ?? 'localhost';
|
const host = process.env.HOST ?? 'localhost';
|
||||||
const port = process.env.PORT ?? 3010;
|
const port = process.env.PORT ?? 3010;
|
||||||
|
|
||||||
|
const config = app.get(Config);
|
||||||
|
|
||||||
|
if (!config.objectStorage.enable) {
|
||||||
|
app.use('/assets', staticMiddleware(config.objectStorage.fs.path));
|
||||||
|
}
|
||||||
|
|
||||||
await app.listen(port, host);
|
await app.listen(port, host);
|
||||||
|
|
||||||
console.log(`Listening on http://${host}:${port}`);
|
console.log(`Listening on http://${host}:${port}`);
|
||||||
|
|||||||
@@ -49,10 +49,17 @@ class AuthGuard implements CanActivate {
|
|||||||
if (!token) {
|
if (!token) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const [type, jwt] = token.split(' ') ?? [];
|
||||||
|
|
||||||
const claims = this.auth.verify(token);
|
if (type === 'Bearer') {
|
||||||
req.user = await this.prisma.user.findUnique({ where: { id: claims.id } });
|
const claims = await this.auth.verify(jwt);
|
||||||
return !!req.user;
|
req.user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: claims.id },
|
||||||
|
});
|
||||||
|
return !!req.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { NextAuthController } from './next-auth.controller';
|
||||||
import { AuthResolver } from './resolver';
|
import { AuthResolver } from './resolver';
|
||||||
import { AuthService } from './service';
|
import { AuthService } from './service';
|
||||||
|
|
||||||
@@ -7,6 +8,7 @@ import { AuthService } from './service';
|
|||||||
@Module({
|
@Module({
|
||||||
providers: [AuthService, AuthResolver],
|
providers: [AuthService, AuthResolver],
|
||||||
exports: [AuthService],
|
exports: [AuthService],
|
||||||
|
controllers: [NextAuthController],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
export * from './guard';
|
export * from './guard';
|
||||||
|
|||||||
158
apps/server/src/modules/auth/next-auth.controller.ts
Normal file
158
apps/server/src/modules/auth/next-auth.controller.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import { PrismaAdapter } from '@auth/prisma-adapter';
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Next,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
|
||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
import type { AuthAction, AuthOptions } from 'next-auth';
|
||||||
|
import { AuthHandler } from 'next-auth/core';
|
||||||
|
import Github from 'next-auth/providers/github';
|
||||||
|
import Google from 'next-auth/providers/google';
|
||||||
|
|
||||||
|
import { Config } from '../../config';
|
||||||
|
import { PrismaService } from '../../prisma/service';
|
||||||
|
import { getUtcTimestamp, type UserClaim } from './service';
|
||||||
|
|
||||||
|
const BASE_URL = '/api/auth/';
|
||||||
|
|
||||||
|
@Controller(BASE_URL)
|
||||||
|
export class NextAuthController {
|
||||||
|
private readonly nextAuthOptions: AuthOptions;
|
||||||
|
|
||||||
|
constructor(readonly config: Config, readonly prisma: PrismaService) {
|
||||||
|
this.nextAuthOptions = {
|
||||||
|
providers: [],
|
||||||
|
// @ts-expect-error Third part library type mismatch
|
||||||
|
adapter: PrismaAdapter(prisma),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (config.auth.oauthProviders.github) {
|
||||||
|
this.nextAuthOptions.providers.push(
|
||||||
|
Github({
|
||||||
|
clientId: config.auth.oauthProviders.github.clientId,
|
||||||
|
clientSecret: config.auth.oauthProviders.github.clientSecret,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.auth.oauthProviders.google) {
|
||||||
|
this.nextAuthOptions.providers.push(
|
||||||
|
Google({
|
||||||
|
clientId: config.auth.oauthProviders.google.clientId,
|
||||||
|
clientSecret: config.auth.oauthProviders.google.clientSecret,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.nextAuthOptions.jwt = {
|
||||||
|
encode: async ({ token, maxAge }) => {
|
||||||
|
if (!token?.email) {
|
||||||
|
throw new BadRequestException('Missing email in jwt token');
|
||||||
|
}
|
||||||
|
const user = await this.prisma.user.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
email: token.email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const now = getUtcTimestamp();
|
||||||
|
return sign(
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
createdAt: user.createdAt.toISOString(),
|
||||||
|
},
|
||||||
|
iat: now,
|
||||||
|
exp: now + (maxAge ?? config.auth.accessTokenExpiresIn),
|
||||||
|
iss: this.config.serverId,
|
||||||
|
sub: user.id,
|
||||||
|
aud: user.name,
|
||||||
|
jti: randomUUID({
|
||||||
|
disableEntropyCache: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
this.config.auth.privateKey,
|
||||||
|
{
|
||||||
|
algorithm: Algorithm.ES256,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
decode: async ({ token }) => {
|
||||||
|
if (!token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { name, email, id } = (
|
||||||
|
await jwtVerify(token, this.config.auth.publicKey, {
|
||||||
|
algorithms: [Algorithm.ES256],
|
||||||
|
iss: [this.config.serverId],
|
||||||
|
leeway: this.config.auth.leeway,
|
||||||
|
requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'],
|
||||||
|
})
|
||||||
|
).data as UserClaim;
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
sub: id,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.nextAuthOptions.secret ??= config.auth.nextAuthSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Post()
|
||||||
|
async auth(
|
||||||
|
@Req() req: Request,
|
||||||
|
@Res() res: Response,
|
||||||
|
@Query() query: Record<string, any>,
|
||||||
|
@Next() next: NextFunction
|
||||||
|
) {
|
||||||
|
const nextauth = req.url // start with request url
|
||||||
|
.slice(BASE_URL.length) // make relative to baseUrl
|
||||||
|
.replace(/\?.*/, '') // remove query part, use only path part
|
||||||
|
.split('/') as AuthAction[]; // as array of strings;
|
||||||
|
const { status, headers, body, redirect, cookies } = await AuthHandler({
|
||||||
|
req: {
|
||||||
|
body: req.body,
|
||||||
|
query: query,
|
||||||
|
method: req.method,
|
||||||
|
action: nextauth[0],
|
||||||
|
providerId: nextauth[1],
|
||||||
|
error: query.error ?? nextauth[1],
|
||||||
|
cookies: req.cookies,
|
||||||
|
},
|
||||||
|
options: this.nextAuthOptions,
|
||||||
|
});
|
||||||
|
if (status) {
|
||||||
|
res.status(status);
|
||||||
|
}
|
||||||
|
if (headers) {
|
||||||
|
for (const { key, value } of headers) {
|
||||||
|
res.setHeader(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cookies) {
|
||||||
|
for (const cookie of cookies) {
|
||||||
|
res.cookie(cookie.name, cookie.value, cookie.options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (redirect) {
|
||||||
|
res.redirect(redirect);
|
||||||
|
} else if (typeof body === 'string') {
|
||||||
|
res.send(body);
|
||||||
|
} else if (body && typeof body === 'object') {
|
||||||
|
res.json(body);
|
||||||
|
} else {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,4 +50,16 @@ export class AuthResolver {
|
|||||||
ctx.req.user = user;
|
ctx.req.user = user;
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Mutation(() => UserType)
|
||||||
|
async signUp(
|
||||||
|
@Context() ctx: { req: Request },
|
||||||
|
@Args('email') email: string,
|
||||||
|
@Args('password') password: string,
|
||||||
|
@Args('name') name: string
|
||||||
|
) {
|
||||||
|
const user = await this.auth.register(name, email, password);
|
||||||
|
ctx.req.user = user;
|
||||||
|
return user;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1,88 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
InternalServerErrorException,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { compare, hash } from '@node-rs/bcrypt';
|
import { hash, verify } from '@node-rs/argon2';
|
||||||
|
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
|
||||||
import type { User } from '@prisma/client';
|
import type { User } from '@prisma/client';
|
||||||
import jwt from 'jsonwebtoken';
|
|
||||||
|
|
||||||
import { Config } from '../../config';
|
import { Config } from '../../config';
|
||||||
import { PrismaService } from '../../prisma';
|
import { PrismaService } from '../../prisma';
|
||||||
|
|
||||||
type UserClaim = Pick<User, 'id' | 'name' | 'email'>;
|
export type UserClaim = Pick<User, 'id' | 'name' | 'email' | 'createdAt'>;
|
||||||
|
|
||||||
|
export const getUtcTimestamp = () => Math.floor(new Date().getTime() / 1000);
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
constructor(private config: Config, private prisma: PrismaService) {}
|
constructor(private config: Config, private prisma: PrismaService) {}
|
||||||
|
|
||||||
sign(user: UserClaim) {
|
sign(user: UserClaim) {
|
||||||
return jwt.sign(user, this.config.auth.privateKey, {
|
const now = getUtcTimestamp();
|
||||||
algorithm: 'ES256',
|
return sign(
|
||||||
subject: user.id,
|
{
|
||||||
issuer: this.config.serverId,
|
data: {
|
||||||
expiresIn: this.config.auth.accessTokenExpiresIn,
|
id: user.id,
|
||||||
});
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
createdAt: user.createdAt.toISOString(),
|
||||||
|
},
|
||||||
|
iat: now,
|
||||||
|
exp: now + this.config.auth.accessTokenExpiresIn,
|
||||||
|
iss: this.config.serverId,
|
||||||
|
sub: user.id,
|
||||||
|
aud: user.name,
|
||||||
|
jti: randomUUID({
|
||||||
|
disableEntropyCache: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
this.config.auth.privateKey,
|
||||||
|
{
|
||||||
|
algorithm: Algorithm.ES256,
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh(user: UserClaim) {
|
refresh(user: UserClaim) {
|
||||||
return jwt.sign(user, this.config.auth.privateKey, {
|
const now = getUtcTimestamp();
|
||||||
algorithm: 'ES256',
|
return sign(
|
||||||
subject: user.id,
|
{
|
||||||
issuer: this.config.serverId,
|
data: {
|
||||||
expiresIn: this.config.auth.refreshTokenExpiresIn,
|
id: user.id,
|
||||||
});
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
createdAt: user.createdAt.toISOString(),
|
||||||
|
},
|
||||||
|
exp: now + this.config.auth.refreshTokenExpiresIn,
|
||||||
|
iat: now,
|
||||||
|
iss: this.config.serverId,
|
||||||
|
sub: user.id,
|
||||||
|
aud: user.name,
|
||||||
|
jti: randomUUID({
|
||||||
|
disableEntropyCache: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
this.config.auth.privateKey,
|
||||||
|
{
|
||||||
|
algorithm: Algorithm.ES256,
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
verify(token: string) {
|
async verify(token: string) {
|
||||||
try {
|
try {
|
||||||
return jwt.verify(token, this.config.auth.publicKey, {
|
return (
|
||||||
algorithms: ['ES256'],
|
await jwtVerify(token, this.config.auth.publicKey, {
|
||||||
}) as UserClaim;
|
algorithms: [Algorithm.ES256],
|
||||||
|
iss: [this.config.serverId],
|
||||||
|
leeway: this.config.auth.leeway,
|
||||||
|
requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'],
|
||||||
|
})
|
||||||
|
).data as UserClaim;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new UnauthorizedException('Invalid token');
|
throw new UnauthorizedException('Invalid token');
|
||||||
}
|
}
|
||||||
@@ -58,9 +102,13 @@ export class AuthService {
|
|||||||
if (!user.password) {
|
if (!user.password) {
|
||||||
throw new BadRequestException('User has no password');
|
throw new BadRequestException('User has no password');
|
||||||
}
|
}
|
||||||
|
let equal = false;
|
||||||
const equal = await compare(password, user.password);
|
try {
|
||||||
|
equal = await verify(user.password, password);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
throw new InternalServerErrorException(e, 'Verify password failed');
|
||||||
|
}
|
||||||
if (!equal) {
|
if (!equal) {
|
||||||
throw new UnauthorizedException('Invalid password');
|
throw new UnauthorizedException('Invalid password');
|
||||||
}
|
}
|
||||||
@@ -69,8 +117,6 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async register(name: string, email: string, password: string): Promise<User> {
|
async register(name: string, email: string, password: string): Promise<User> {
|
||||||
const hashedPassword = await hash(password);
|
|
||||||
|
|
||||||
const user = await this.prisma.user.findFirst({
|
const user = await this.prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
email,
|
email,
|
||||||
@@ -81,6 +127,8 @@ export class AuthService {
|
|||||||
throw new BadRequestException('Email already exists');
|
throw new BadRequestException('Email already exists');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await hash(password);
|
||||||
|
|
||||||
return this.prisma.user.create({
|
return this.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
name,
|
name,
|
||||||
|
|||||||
23
apps/server/src/modules/storage/fs.ts
Normal file
23
apps/server/src/modules/storage/fs.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { createWriteStream } from 'node:fs';
|
||||||
|
import { mkdir } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { pipeline } from 'node:stream/promises';
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { Config } from '../../config';
|
||||||
|
import { FileUpload } from '../../types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FSService {
|
||||||
|
constructor(private readonly config: Config) {}
|
||||||
|
|
||||||
|
async writeFile(key: string, file: FileUpload) {
|
||||||
|
const dest = this.config.objectStorage.fs.path;
|
||||||
|
await mkdir(dest, { recursive: true });
|
||||||
|
const destFile = join(dest, key);
|
||||||
|
await pipeline(file.createReadStream(), createWriteStream(destFile));
|
||||||
|
|
||||||
|
return `/assets/${destFile}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/server/src/modules/storage/index.ts
Normal file
11
apps/server/src/modules/storage/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { FSService } from './fs';
|
||||||
|
import { S3 } from './s3';
|
||||||
|
import { StorageService } from './storage.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [S3, StorageService, FSService],
|
||||||
|
exports: [StorageService],
|
||||||
|
})
|
||||||
|
export class StorageModule {}
|
||||||
15
apps/server/src/modules/storage/s3.ts
Normal file
15
apps/server/src/modules/storage/s3.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { S3Client } from '@aws-sdk/client-s3';
|
||||||
|
import { FactoryProvider } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { Config } from '../../config';
|
||||||
|
|
||||||
|
export const S3_SERVICE = Symbol('S3_SERVICE');
|
||||||
|
|
||||||
|
export const S3: FactoryProvider<S3Client> = {
|
||||||
|
provide: S3_SERVICE,
|
||||||
|
useFactory: (config: Config) => {
|
||||||
|
const s3 = new S3Client(config.objectStorage.config);
|
||||||
|
return s3;
|
||||||
|
},
|
||||||
|
inject: [Config],
|
||||||
|
};
|
||||||
31
apps/server/src/modules/storage/storage.service.ts
Normal file
31
apps/server/src/modules/storage/storage.service.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { Config } from '../../config';
|
||||||
|
import { FileUpload } from '../../types';
|
||||||
|
import { FSService } from './fs';
|
||||||
|
import { S3_SERVICE } from './s3';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StorageService {
|
||||||
|
constructor(
|
||||||
|
@Inject(S3_SERVICE) private readonly s3: S3Client,
|
||||||
|
private readonly fs: FSService,
|
||||||
|
private readonly config: Config
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async uploadFile(key: string, file: FileUpload) {
|
||||||
|
if (this.config.objectStorage.enable) {
|
||||||
|
await this.s3.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Body: file.createReadStream(),
|
||||||
|
Bucket: this.config.objectStorage.config.bucket,
|
||||||
|
Key: key,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return `https://avatar.affineassets.com/${key}`;
|
||||||
|
} else {
|
||||||
|
return this.fs.writeFile(key, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { StorageModule } from '../storage';
|
||||||
import { UserResolver } from './resolver';
|
import { UserResolver } from './resolver';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [StorageModule],
|
||||||
providers: [UserResolver],
|
providers: [UserResolver],
|
||||||
})
|
})
|
||||||
export class UsersModule {}
|
export class UsersModule {}
|
||||||
|
|||||||
@@ -1,7 +1,21 @@
|
|||||||
import { Args, Field, ID, ObjectType, Query, Resolver } from '@nestjs/graphql';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
Args,
|
||||||
|
Field,
|
||||||
|
ID,
|
||||||
|
Mutation,
|
||||||
|
ObjectType,
|
||||||
|
Query,
|
||||||
|
Resolver,
|
||||||
|
} from '@nestjs/graphql';
|
||||||
import type { User } from '@prisma/client';
|
import type { User } from '@prisma/client';
|
||||||
|
// @ts-expect-error graphql-upload is not typed
|
||||||
|
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||||
|
|
||||||
import { PrismaService } from '../../prisma/service';
|
import { PrismaService } from '../../prisma/service';
|
||||||
|
import type { FileUpload } from '../../types';
|
||||||
|
import { Auth } from '../auth/guard';
|
||||||
|
import { StorageService } from '../storage/storage.service';
|
||||||
|
|
||||||
@ObjectType()
|
@ObjectType()
|
||||||
export class UserType implements Partial<User> {
|
export class UserType implements Partial<User> {
|
||||||
@@ -21,9 +35,13 @@ export class UserType implements Partial<User> {
|
|||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Auth()
|
||||||
@Resolver(() => UserType)
|
@Resolver(() => UserType)
|
||||||
export class UserResolver {
|
export class UserResolver {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly storage: StorageService
|
||||||
|
) {}
|
||||||
|
|
||||||
@Query(() => UserType, {
|
@Query(() => UserType, {
|
||||||
name: 'user',
|
name: 'user',
|
||||||
@@ -34,4 +52,24 @@ export class UserResolver {
|
|||||||
where: { email },
|
where: { email },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Mutation(() => UserType, {
|
||||||
|
name: 'uploadAvatar',
|
||||||
|
description: 'Upload user avatar',
|
||||||
|
})
|
||||||
|
async uploadAvatar(
|
||||||
|
@Args('id') id: string,
|
||||||
|
@Args({ name: 'avatar', type: () => GraphQLUpload })
|
||||||
|
avatar: FileUpload
|
||||||
|
) {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||||
|
if (!user) {
|
||||||
|
throw new BadRequestException(`User ${id} not found`);
|
||||||
|
}
|
||||||
|
const url = await this.storage.uploadFile(`${id}-avatar`, avatar);
|
||||||
|
return this.prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { avatarUrl: url },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { equal, ok } from 'node:assert';
|
import { equal, ok } from 'node:assert';
|
||||||
import { afterEach, beforeEach, describe, test } from 'node:test';
|
import { afterEach, beforeEach, describe, test } from 'node:test';
|
||||||
|
|
||||||
|
import { Transformer } from '@napi-rs/image';
|
||||||
import type { INestApplication } from '@nestjs/common';
|
import type { INestApplication } from '@nestjs/common';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { hash } from '@node-rs/bcrypt';
|
import { hash } from '@node-rs/argon2';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { Express } from 'express';
|
||||||
|
// @ts-expect-error graphql-upload is not typed
|
||||||
|
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
|
|
||||||
import { AppModule } from '../app';
|
import { AppModule } from '../app';
|
||||||
@@ -24,7 +28,6 @@ describe('AppModule', () => {
|
|||||||
await client.user.deleteMany({});
|
await client.user.deleteMany({});
|
||||||
await client.user.create({
|
await client.user.create({
|
||||||
data: {
|
data: {
|
||||||
id: '1',
|
|
||||||
name: 'Alex Yang',
|
name: 'Alex Yang',
|
||||||
email: 'alex.yang@example.org',
|
email: 'alex.yang@example.org',
|
||||||
password: await hash('123456'),
|
password: await hash('123456'),
|
||||||
@@ -36,7 +39,16 @@ describe('AppModule', () => {
|
|||||||
const module = await Test.createTestingModule({
|
const module = await Test.createTestingModule({
|
||||||
imports: [AppModule],
|
imports: [AppModule],
|
||||||
}).compile();
|
}).compile();
|
||||||
app = module.createNestApplication();
|
app = module.createNestApplication({
|
||||||
|
cors: true,
|
||||||
|
bodyParser: true,
|
||||||
|
});
|
||||||
|
app.use(
|
||||||
|
graphqlUploadExpress({
|
||||||
|
maxFileSize: 10 * 1024 * 1024,
|
||||||
|
maxFiles: 5,
|
||||||
|
})
|
||||||
|
);
|
||||||
await app.init();
|
await app.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,32 +69,11 @@ describe('AppModule', () => {
|
|||||||
})
|
})
|
||||||
.expect(400);
|
.expect(400);
|
||||||
|
|
||||||
let token;
|
const { token } = await createToken(app);
|
||||||
await request(app.getHttpServer())
|
|
||||||
.post(gql)
|
|
||||||
.send({
|
|
||||||
query: `
|
|
||||||
mutation {
|
|
||||||
signIn(email: "alex.yang@example.org", password: "123456") {
|
|
||||||
token {
|
|
||||||
token
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
})
|
|
||||||
.expect(200)
|
|
||||||
.expect(res => {
|
|
||||||
ok(
|
|
||||||
typeof res.body.data.signIn.token.token === 'string',
|
|
||||||
'res.body.data.signIn.token.token is not a string'
|
|
||||||
);
|
|
||||||
token = res.body.data.signIn.token.token;
|
|
||||||
});
|
|
||||||
|
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.post(gql)
|
.post(gql)
|
||||||
.set({ Authorization: token })
|
.auth(token, { type: 'bearer' })
|
||||||
.send({
|
.send({
|
||||||
query: `
|
query: `
|
||||||
mutation {
|
mutation {
|
||||||
@@ -116,8 +107,10 @@ describe('AppModule', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should find default user', async () => {
|
test('should find default user', async () => {
|
||||||
|
const { token } = await createToken(app);
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.post(gql)
|
.post(gql)
|
||||||
|
.auth(token, { type: 'bearer' })
|
||||||
.send({
|
.send({
|
||||||
query: `
|
query: `
|
||||||
query {
|
query {
|
||||||
@@ -133,4 +126,72 @@ describe('AppModule', () => {
|
|||||||
equal(res.body.data.user.email, 'alex.yang@example.org');
|
equal(res.body.data.user.email, 'alex.yang@example.org');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should be able to upload avatar', async () => {
|
||||||
|
const { token, id } = await createToken(app);
|
||||||
|
const png = await Transformer.fromRgbaPixels(
|
||||||
|
Buffer.alloc(400 * 400 * 4).fill(255),
|
||||||
|
400,
|
||||||
|
400
|
||||||
|
).png();
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(gql)
|
||||||
|
.auth(token, { type: 'bearer' })
|
||||||
|
.field(
|
||||||
|
'operations',
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'uploadAvatar',
|
||||||
|
query: `mutation uploadAvatar($id: String!, $avatar: Upload!) {
|
||||||
|
uploadAvatar(id: $id, avatar: $avatar) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
avatarUrl
|
||||||
|
email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
variables: { id, avatar: null },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
.field('map', JSON.stringify({ '0': ['variables.avatar'] }))
|
||||||
|
.attach('0', png, 'avatar.png')
|
||||||
|
.expect(200)
|
||||||
|
.expect(res => {
|
||||||
|
equal(res.body.data.uploadAvatar.id, id);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function createToken(app: INestApplication<Express>): Promise<{
|
||||||
|
id: string;
|
||||||
|
token: string;
|
||||||
|
}> {
|
||||||
|
let token;
|
||||||
|
let id;
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(gql)
|
||||||
|
.send({
|
||||||
|
query: `
|
||||||
|
mutation {
|
||||||
|
signIn(email: "alex.yang@example.org", password: "123456") {
|
||||||
|
id
|
||||||
|
token {
|
||||||
|
token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
.expect(200)
|
||||||
|
.expect(res => {
|
||||||
|
id = res.body.data.signIn.id;
|
||||||
|
ok(
|
||||||
|
typeof res.body.data.signIn.token.token === 'string',
|
||||||
|
'res.body.data.signIn.token.token is not a string'
|
||||||
|
);
|
||||||
|
token = res.body.data.signIn.token.token;
|
||||||
|
});
|
||||||
|
return { token: token!, id: id! };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { ok, throws } from 'node:assert';
|
import { ok } from 'node:assert';
|
||||||
import { beforeEach, test } from 'node:test';
|
import { beforeEach, test } from 'node:test';
|
||||||
|
|
||||||
import { UnauthorizedException } from '@nestjs/common';
|
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
@@ -28,8 +27,9 @@ beforeEach(async () => {
|
|||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
auth: {
|
auth: {
|
||||||
accessTokenExpiresIn: '1s',
|
accessTokenExpiresIn: 1,
|
||||||
refreshTokenExpiresIn: '3s',
|
refreshTokenExpiresIn: 1,
|
||||||
|
leeway: 1,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
@@ -40,12 +40,6 @@ beforeEach(async () => {
|
|||||||
auth = module.get(AuthService);
|
auth = module.get(AuthService);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function sleep(ms: number) {
|
|
||||||
return new Promise<void>(resolve => {
|
|
||||||
setTimeout(resolve, ms);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test('should be able to register and signIn', async () => {
|
test('should be able to register and signIn', async () => {
|
||||||
await auth.register('Alex Yang', 'alexyang@example.org', '123456');
|
await auth.register('Alex Yang', 'alexyang@example.org', '123456');
|
||||||
await auth.signIn('alexyang@example.org', '123456');
|
await auth.signIn('alexyang@example.org', '123456');
|
||||||
@@ -58,23 +52,20 @@ test('should be able to verify', async () => {
|
|||||||
id: '1',
|
id: '1',
|
||||||
name: 'Alex Yang',
|
name: 'Alex Yang',
|
||||||
email: 'alexyang@example.org',
|
email: 'alexyang@example.org',
|
||||||
|
createdAt: new Date(),
|
||||||
};
|
};
|
||||||
{
|
{
|
||||||
const token = auth.sign(user);
|
const token = await auth.sign(user);
|
||||||
const clain = auth.verify(token);
|
const claim = await auth.verify(token);
|
||||||
ok(clain.id === '1');
|
ok(claim.id === '1');
|
||||||
ok(clain.name === 'Alex Yang');
|
ok(claim.name === 'Alex Yang');
|
||||||
ok(clain.email === 'alexyang@example.org');
|
ok(claim.email === 'alexyang@example.org');
|
||||||
await sleep(1050);
|
|
||||||
throws(() => auth.verify(token), UnauthorizedException, 'Invalid token');
|
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
const token = auth.refresh(user);
|
const token = await auth.refresh(user);
|
||||||
const clain = auth.verify(token);
|
const claim = await auth.verify(token);
|
||||||
ok(clain.id === '1');
|
ok(claim.id === '1');
|
||||||
ok(clain.name === 'Alex Yang');
|
ok(claim.name === 'Alex Yang');
|
||||||
ok(clain.email === 'alexyang@example.org');
|
ok(claim.email === 'alexyang@example.org');
|
||||||
await sleep(3050);
|
|
||||||
throws(() => auth.verify(token), UnauthorizedException, 'Invalid token');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
8
apps/server/src/types.ts
Normal file
8
apps/server/src/types.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Readable } from 'node:stream';
|
||||||
|
|
||||||
|
export interface FileUpload {
|
||||||
|
filename: string;
|
||||||
|
mimetype: string;
|
||||||
|
encoding: string;
|
||||||
|
createReadStream: () => Readable;
|
||||||
|
}
|
||||||
@@ -6,12 +6,13 @@
|
|||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "Bundler",
|
||||||
"isolatedModules": false,
|
"isolatedModules": false,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"noEmit": false
|
"noEmit": false,
|
||||||
|
"verbatimModuleSyntax": false
|
||||||
},
|
},
|
||||||
"include": ["src", "package.json"],
|
"include": ["src", "package.json"],
|
||||||
"exclude": ["dist", "node_modules"],
|
"exclude": ["dist", "node_modules"],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* @vitest-environment happy-dom
|
* @vitest-environment happy-dom
|
||||||
*/
|
*/
|
||||||
|
import { uploadAvatarMutation } from '@affine/graphql';
|
||||||
import { render } from '@testing-library/react';
|
import { render } from '@testing-library/react';
|
||||||
import type { Mock } from 'vitest';
|
import type { Mock } from 'vitest';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
@@ -60,11 +61,11 @@ describe('GraphQL wrapper for SWR', () => {
|
|||||||
const renderer = render(component);
|
const renderer = render(component);
|
||||||
const el = await renderer.findByText('number: 1');
|
const el = await renderer.findByText('number: 1');
|
||||||
expect(el).toMatchInlineSnapshot(`
|
expect(el).toMatchInlineSnapshot(`
|
||||||
<div>
|
<div>
|
||||||
number:
|
number:
|
||||||
1
|
1
|
||||||
</div>
|
</div>
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not send request if cache hit', async () => {
|
it('should not send request if cache hit', async () => {
|
||||||
@@ -135,5 +136,18 @@ describe('GraphQL wrapper for SWR', () => {
|
|||||||
</DocumentFragment>
|
</DocumentFragment>
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should get rid of generated types', async () => {
|
||||||
|
function _NotActuallyRunDefinedForTypeTesting() {
|
||||||
|
const { trigger } = useMutation({
|
||||||
|
mutation: uploadAvatarMutation,
|
||||||
|
});
|
||||||
|
trigger({
|
||||||
|
id: '1',
|
||||||
|
avatar: new File([''], 'avatar.png'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(_NotActuallyRunDefinedForTypeTesting).toBeTypeOf('function');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,7 +50,10 @@ export function useQuery<Query extends GraphQLQuery>(
|
|||||||
'fetcher'
|
'fetcher'
|
||||||
>
|
>
|
||||||
): SWRResponse<QueryResponse<Query>, GraphQLError | GraphQLError[]>;
|
): SWRResponse<QueryResponse<Query>, GraphQLError | GraphQLError[]>;
|
||||||
export function useQuery(options: QueryOptions<GraphQLQuery>, config?: any) {
|
export function useQuery<Query extends GraphQLQuery>(
|
||||||
|
options: QueryOptions<Query>,
|
||||||
|
config?: any
|
||||||
|
) {
|
||||||
return useSWR(
|
return useSWR(
|
||||||
() => [options.query.id, options.variables],
|
() => [options.query.id, options.variables],
|
||||||
() => fetcher(options),
|
() => fetcher(options),
|
||||||
@@ -73,14 +76,14 @@ export function useQuery(options: QueryOptions<GraphQLQuery>, config?: any) {
|
|||||||
* trigger({ name: 'John Doe' })
|
* trigger({ name: 'John Doe' })
|
||||||
*/
|
*/
|
||||||
export function useMutation<Mutation extends GraphQLQuery>(
|
export function useMutation<Mutation extends GraphQLQuery>(
|
||||||
options: Omit<MutationOptions<GraphQLQuery>, 'variables'>
|
options: Omit<MutationOptions<Mutation>, 'variables'>
|
||||||
): SWRMutationResponse<
|
): SWRMutationResponse<
|
||||||
QueryResponse<Mutation>,
|
QueryResponse<Mutation>,
|
||||||
GraphQLError | GraphQLError[],
|
GraphQLError | GraphQLError[],
|
||||||
QueryVariables<Mutation>
|
QueryVariables<Mutation>
|
||||||
>;
|
>;
|
||||||
export function useMutation<Mutation extends GraphQLQuery>(
|
export function useMutation<Mutation extends GraphQLQuery>(
|
||||||
options: Omit<MutationOptions<GraphQLQuery>, 'variables'>,
|
options: Omit<MutationOptions<Mutation>, 'variables'>,
|
||||||
config: Omit<
|
config: Omit<
|
||||||
SWRMutationConfiguration<
|
SWRMutationConfiguration<
|
||||||
QueryResponse<Mutation>,
|
QueryResponse<Mutation>,
|
||||||
@@ -100,7 +103,7 @@ export function useMutation(
|
|||||||
) {
|
) {
|
||||||
return useSWRMutation(
|
return useSWRMutation(
|
||||||
options.mutation.id,
|
options.mutation.id,
|
||||||
(_: string, { arg }: { arg: QueryVariables<any> }) =>
|
(_: string, { arg }: { arg: any }) =>
|
||||||
fetcher({ ...options, query: options.mutation, variables: arg }),
|
fetcher({ ...options, query: options.mutation, variables: arg }),
|
||||||
config
|
config
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ config:
|
|||||||
UUID: string
|
UUID: string
|
||||||
ID: string
|
ID: string
|
||||||
JSON: any
|
JSON: any
|
||||||
|
Upload: File
|
||||||
overwrite: true
|
overwrite: true
|
||||||
schema: ../../apps/server/src/schema.gql
|
schema: ../../apps/server/src/schema.gql
|
||||||
documents: ./src/**/*.gql
|
documents: ./src/**/*.gql
|
||||||
|
|||||||
@@ -129,10 +129,26 @@ module.exports = {
|
|||||||
.map(field => field.name.value)
|
.map(field => field.name.value)
|
||||||
.join(',');
|
.join(',');
|
||||||
nameLocationMap.set(exportedName, location);
|
nameLocationMap.set(exportedName, location);
|
||||||
|
const containsFile = doc.definitions.some(def => {
|
||||||
|
const { variableDefinitions } = def;
|
||||||
|
if (variableDefinitions) {
|
||||||
|
return variableDefinitions.some(variableDefinition => {
|
||||||
|
if (
|
||||||
|
variableDefinition?.type?.type?.name?.value === 'Upload'
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
defs.push(`export const ${exportedName} = {
|
defs.push(`export const ${exportedName} = {
|
||||||
id: '${exportedName}' as const,
|
id: '${exportedName}' as const,
|
||||||
operationName: '${doc.operationName}',
|
operationName: '${doc.operationName}',
|
||||||
definitionName: '${doc.defName}',
|
definitionName: '${doc.defName}',
|
||||||
|
containsFile: ${containsFile},
|
||||||
query: \`
|
query: \`
|
||||||
${print(doc)}${importing || ''}\`,
|
${print(doc)}${importing || ''}\`,
|
||||||
}
|
}
|
||||||
@@ -159,6 +175,7 @@ ${print(doc)}${importing || ''}\``);
|
|||||||
operationName: string
|
operationName: string
|
||||||
definitionName: string
|
definitionName: string
|
||||||
query: string
|
query: string
|
||||||
|
containsFile?: boolean
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
...defs,
|
...defs,
|
||||||
|
|||||||
@@ -18,6 +18,9 @@
|
|||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"prettier": "^2.8.8"
|
"prettier": "^2.8.8"
|
||||||
},
|
},
|
||||||
|
"scripts": {
|
||||||
|
"postinstall": "gql-gen"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"graphql": "^16.6.0"
|
"graphql": "^16.6.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,8 @@ describe('GraphQL fetcher', () => {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(gql({ query, variables: {} })).rejects.toMatchInlineSnapshot(`
|
await expect(gql({ query, variables: void 0 })).rejects
|
||||||
|
.toMatchInlineSnapshot(`
|
||||||
[
|
[
|
||||||
[GraphQLError: error],
|
[GraphQLError: error],
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import type { Mutations, Queries } from './schema';
|
|||||||
|
|
||||||
export type NotArray<T> = T extends Array<unknown> ? never : T;
|
export type NotArray<T> = T extends Array<unknown> ? never : T;
|
||||||
|
|
||||||
type _QueryVariables<Q extends GraphQLQuery> = Extract<
|
export type _QueryVariables<Q extends GraphQLQuery> =
|
||||||
Queries | Mutations,
|
Q['id'] extends Queries['name']
|
||||||
{ name: Q['id'] }
|
? Extract<Queries, { name: Q['id'] }>['variables']
|
||||||
>['variables'];
|
: Q['id'] extends Mutations['name']
|
||||||
|
? Extract<Mutations, { name: Q['id'] }>['variables']
|
||||||
|
: undefined;
|
||||||
|
|
||||||
export type QueryVariables<Q extends GraphQLQuery> = _QueryVariables<Q> extends
|
export type QueryVariables<Q extends GraphQLQuery> = _QueryVariables<Q> extends
|
||||||
| never
|
| never
|
||||||
@@ -65,13 +67,6 @@ export type RequestOptions<Q extends GraphQLQuery> = QueryVariablesOption<Q> & {
|
|||||||
* parameter passed to `fetch` function
|
* parameter passed to `fetch` function
|
||||||
*/
|
*/
|
||||||
context?: AllowedRequestContext;
|
context?: AllowedRequestContext;
|
||||||
/**
|
|
||||||
* files need to be uploaded
|
|
||||||
*
|
|
||||||
* When provided, the request body will be turned to multiparts to satisfy
|
|
||||||
* file uploading scene.
|
|
||||||
*/
|
|
||||||
files?: File[];
|
|
||||||
/**
|
/**
|
||||||
* Whether keep null or undefined value in variables.
|
* Whether keep null or undefined value in variables.
|
||||||
*
|
*
|
||||||
@@ -105,27 +100,43 @@ function filterEmptyValue(vars: any) {
|
|||||||
return newVars;
|
return newVars;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function appendFormData(body: RequestBody, files: File[]) {
|
export function transformToForm(body: RequestBody) {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
|
const gqlBody: {
|
||||||
|
name?: string;
|
||||||
|
query: string;
|
||||||
|
variables: any;
|
||||||
|
map: any;
|
||||||
|
} = {
|
||||||
|
query: body.query,
|
||||||
|
variables: body.variables,
|
||||||
|
map: {},
|
||||||
|
};
|
||||||
|
|
||||||
if (body.operationName) {
|
if (body.operationName) {
|
||||||
form.append('operationName', body.operationName);
|
gqlBody.name = body.operationName;
|
||||||
}
|
}
|
||||||
form.append('query', body.query);
|
|
||||||
form.append('variables', JSON.stringify(body.variables));
|
|
||||||
files.forEach(file => {
|
|
||||||
form.append(file.name, file);
|
|
||||||
});
|
|
||||||
|
|
||||||
body.form = form;
|
if (body.variables) {
|
||||||
|
let i = 0;
|
||||||
|
Object.entries(body.variables).forEach(([key, value]) => {
|
||||||
|
if (value instanceof File) {
|
||||||
|
gqlBody.map['0'] = [`variables.${key}`];
|
||||||
|
form.append(`${i}`, value);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
form.append('operations', JSON.stringify(gqlBody));
|
||||||
|
return form;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRequestBody<Q extends GraphQLQuery>({
|
function formatRequestBody<Q extends GraphQLQuery>({
|
||||||
query,
|
query,
|
||||||
variables,
|
variables,
|
||||||
keepNilVariables,
|
keepNilVariables,
|
||||||
files,
|
}: QueryOptions<Q>): RequestBody | FormData {
|
||||||
}: QueryOptions<Q>): RequestBody {
|
|
||||||
const body: RequestBody = {
|
const body: RequestBody = {
|
||||||
query: query.query,
|
query: query.query,
|
||||||
variables:
|
variables:
|
||||||
@@ -136,10 +147,9 @@ function formatRequestBody<Q extends GraphQLQuery>({
|
|||||||
body.operationName = query.operationName;
|
body.operationName = query.operationName;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (files?.length) {
|
if (query.containsFile) {
|
||||||
appendFormData(body, files);
|
return transformToForm(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +167,7 @@ export const gqlFetcherFactory = (endpoint: string) => {
|
|||||||
'x-operation-name': options.query.operationName,
|
'x-operation-name': options.query.operationName,
|
||||||
'x-definition-name': options.query.definitionName,
|
'x-definition-name': options.query.definitionName,
|
||||||
},
|
},
|
||||||
body: body.form ?? JSON.stringify(body),
|
body: body instanceof FormData ? body : JSON.stringify(body),
|
||||||
})
|
})
|
||||||
).then(async res => {
|
).then(async res => {
|
||||||
if (res.headers.get('content-type') === 'application/json') {
|
if (res.headers.get('content-type') === 'application/json') {
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ mutation createWorkspace {
|
|||||||
createWorkspace {
|
createWorkspace {
|
||||||
id
|
id
|
||||||
public
|
public
|
||||||
created_at
|
createdAt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,18 +4,36 @@ export interface GraphQLQuery {
|
|||||||
operationName: string;
|
operationName: string;
|
||||||
definitionName: string;
|
definitionName: string;
|
||||||
query: string;
|
query: string;
|
||||||
|
containsFile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createWorkspaceMutation = {
|
export const createWorkspaceMutation = {
|
||||||
id: 'createWorkspaceMutation' as const,
|
id: 'createWorkspaceMutation' as const,
|
||||||
operationName: 'createWorkspace',
|
operationName: 'createWorkspace',
|
||||||
definitionName: 'createWorkspace',
|
definitionName: 'createWorkspace',
|
||||||
|
containsFile: false,
|
||||||
query: `
|
query: `
|
||||||
mutation createWorkspace {
|
mutation createWorkspace {
|
||||||
createWorkspace {
|
createWorkspace {
|
||||||
id
|
id
|
||||||
public
|
public
|
||||||
created_at
|
createdAt
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uploadAvatarMutation = {
|
||||||
|
id: 'uploadAvatarMutation' as const,
|
||||||
|
operationName: 'uploadAvatar',
|
||||||
|
definitionName: 'uploadAvatar',
|
||||||
|
containsFile: true,
|
||||||
|
query: `
|
||||||
|
mutation uploadAvatar($id: String!, $avatar: Upload!) {
|
||||||
|
uploadAvatar(id: $id, avatar: $avatar) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
avatarUrl
|
||||||
|
email
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
};
|
};
|
||||||
@@ -24,13 +42,13 @@ export const workspaceByIdQuery = {
|
|||||||
id: 'workspaceByIdQuery' as const,
|
id: 'workspaceByIdQuery' as const,
|
||||||
operationName: 'workspaceById',
|
operationName: 'workspaceById',
|
||||||
definitionName: 'workspace',
|
definitionName: 'workspace',
|
||||||
|
containsFile: false,
|
||||||
query: `
|
query: `
|
||||||
query workspaceById($id: String!) {
|
query workspaceById($id: String!) {
|
||||||
workspace(id: $id) {
|
workspace(id: $id) {
|
||||||
id
|
id
|
||||||
type
|
|
||||||
public
|
public
|
||||||
created_at
|
createdAt
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
};
|
};
|
||||||
|
|||||||
8
packages/graphql/src/graphql/upload-avatar.gql
Normal file
8
packages/graphql/src/graphql/upload-avatar.gql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
mutation uploadAvatar($id: String!, $avatar: Upload!) {
|
||||||
|
uploadAvatar(id: $id, avatar: $avatar) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
avatarUrl
|
||||||
|
email
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
query workspaceById($id: String!) {
|
query workspaceById($id: String!) {
|
||||||
workspace(id: $id) {
|
workspace(id: $id) {
|
||||||
id
|
id
|
||||||
type
|
|
||||||
public
|
public
|
||||||
created_at
|
createdAt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,23 +10,40 @@ export type MakeOptional<T, K extends keyof T> = Omit<T, K> & {
|
|||||||
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & {
|
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & {
|
||||||
[SubKey in K]: Maybe<T[SubKey]>;
|
[SubKey in K]: Maybe<T[SubKey]>;
|
||||||
};
|
};
|
||||||
|
export type MakeEmpty<
|
||||||
|
T extends { [key: string]: unknown },
|
||||||
|
K extends keyof T
|
||||||
|
> = { [_ in K]?: never };
|
||||||
|
export type Incremental<T> =
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
[P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never;
|
||||||
|
};
|
||||||
/** All built-in and custom scalars, mapped to their actual values */
|
/** All built-in and custom scalars, mapped to their actual values */
|
||||||
export interface Scalars {
|
export interface Scalars {
|
||||||
ID: string;
|
ID: { input: string; output: string };
|
||||||
String: string;
|
String: { input: string; output: string };
|
||||||
Boolean: boolean;
|
Boolean: { input: boolean; output: boolean };
|
||||||
Int: number;
|
Int: { input: number; output: number };
|
||||||
Float: number;
|
Float: { input: number; output: number };
|
||||||
/** A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. */
|
/** A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. */
|
||||||
DateTime: string;
|
DateTime: { input: string; output: string };
|
||||||
|
/** The `Upload` scalar type represents a file upload. */
|
||||||
|
Upload: { input: File; output: File };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Workspace type */
|
/** User permission in workspace */
|
||||||
export enum WorkspaceType {
|
export enum Permission {
|
||||||
/** Normal workspace */
|
Admin = 'Admin',
|
||||||
Normal = 'Normal',
|
Owner = 'Owner',
|
||||||
/** Private workspace */
|
Read = 'Read',
|
||||||
Private = 'Private',
|
Write = 'Write',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateWorkspaceInput {
|
||||||
|
id: Scalars['ID']['input'];
|
||||||
|
/** is Public workspace */
|
||||||
|
public: InputMaybe<Scalars['Boolean']['input']>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CreateWorkspaceMutationVariables = Exact<{ [key: string]: never }>;
|
export type CreateWorkspaceMutationVariables = Exact<{ [key: string]: never }>;
|
||||||
@@ -34,25 +51,40 @@ export type CreateWorkspaceMutationVariables = Exact<{ [key: string]: never }>;
|
|||||||
export type CreateWorkspaceMutation = {
|
export type CreateWorkspaceMutation = {
|
||||||
__typename?: 'Mutation';
|
__typename?: 'Mutation';
|
||||||
createWorkspace: {
|
createWorkspace: {
|
||||||
__typename?: 'Workspace';
|
__typename?: 'WorkspaceType';
|
||||||
id: string;
|
id: string;
|
||||||
public: boolean;
|
public: boolean;
|
||||||
created_at: string;
|
createdAt: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UploadAvatarMutationVariables = Exact<{
|
||||||
|
id: Scalars['String']['input'];
|
||||||
|
avatar: Scalars['Upload']['input'];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type UploadAvatarMutation = {
|
||||||
|
__typename?: 'Mutation';
|
||||||
|
uploadAvatar: {
|
||||||
|
__typename?: 'UserType';
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
email: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkspaceByIdQueryVariables = Exact<{
|
export type WorkspaceByIdQueryVariables = Exact<{
|
||||||
id: Scalars['String'];
|
id: Scalars['String']['input'];
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type WorkspaceByIdQuery = {
|
export type WorkspaceByIdQuery = {
|
||||||
__typename?: 'Query';
|
__typename?: 'Query';
|
||||||
workspace: {
|
workspace: {
|
||||||
__typename?: 'Workspace';
|
__typename?: 'WorkspaceType';
|
||||||
id: string;
|
id: string;
|
||||||
type: WorkspaceType;
|
|
||||||
public: boolean;
|
public: boolean;
|
||||||
created_at: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,8 +94,14 @@ export type Queries = {
|
|||||||
response: WorkspaceByIdQuery;
|
response: WorkspaceByIdQuery;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Mutations = {
|
export type Mutations =
|
||||||
name: 'createWorkspaceMutation';
|
| {
|
||||||
variables: CreateWorkspaceMutationVariables;
|
name: 'createWorkspaceMutation';
|
||||||
response: CreateWorkspaceMutation;
|
variables: CreateWorkspaceMutationVariables;
|
||||||
};
|
response: CreateWorkspaceMutation;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
name: 'uploadAvatarMutation';
|
||||||
|
variables: UploadAvatarMutationVariables;
|
||||||
|
response: UploadAvatarMutation;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user