feat(server): auth server (#2773)

This commit is contained in:
LongYinan
2023-06-21 14:08:32 +08:00
committed by GitHub
parent 2698e7fd0d
commit 9b3fa43b81
36 changed files with 4089 additions and 2013 deletions

View File

@@ -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;

View File

@@ -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",

View File

@@ -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")
}

View File

@@ -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);

View File

@@ -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
*/ */

View File

@@ -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'),
},
}, },
}); });

View File

@@ -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),
'..', '..',

View File

@@ -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}`);

View File

@@ -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;
} }
} }

View File

@@ -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';

View 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();
}
}
}

View File

@@ -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;
}
} }

View File

@@ -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,

View 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}`;
}
}

View 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 {}

View 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],
};

View 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);
}
}
}

View 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 {}

View File

@@ -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 },
});
}
} }

View File

@@ -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! };
}

View File

@@ -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
View File

@@ -0,0 +1,8 @@
import { Readable } from 'node:stream';
export interface FileUpload {
filename: string;
mimetype: string;
encoding: string;
createReadStream: () => Readable;
}

View File

@@ -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"],

View File

@@ -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');
});
}); });
}); });

View File

@@ -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
); );

View File

@@ -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

View File

@@ -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,

View File

@@ -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"
} }

View File

@@ -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],
] ]

View File

@@ -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') {

View File

@@ -2,6 +2,6 @@ mutation createWorkspace {
createWorkspace { createWorkspace {
id id
public public
created_at createdAt
} }
} }

View File

@@ -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
} }
}`, }`,
}; };

View File

@@ -0,0 +1,8 @@
mutation uploadAvatar($id: String!, $avatar: Upload!) {
uploadAvatar(id: $id, avatar: $avatar) {
id
name
avatarUrl
email
}
}

View File

@@ -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
} }
} }

View File

@@ -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;
};

5032
yarn.lock

File diff suppressed because it is too large Load Diff