diff --git a/apps/server/.env.example b/apps/server/.env.example
index 46dafb290..a81ef7700 100644
--- a/apps/server/.env.example
+++ b/apps/server/.env.example
@@ -1 +1,2 @@
+SECRET_KEY="secret"
DATABASE_URL="postgresql://affine@localhost:5432/affine"
diff --git a/apps/server/migrations/20230418212743_init/migration.sql b/apps/server/migrations/20230418212743_init/migration.sql
deleted file mode 100644
index da95ce145..000000000
--- a/apps/server/migrations/20230418212743_init/migration.sql
+++ /dev/null
@@ -1,67 +0,0 @@
--- CreateTable
-CREATE TABLE "google_users" (
- "id" VARCHAR NOT NULL,
- "user_id" VARCHAR NOT NULL,
- "google_id" VARCHAR NOT NULL,
-
- CONSTRAINT "google_users_pkey" PRIMARY KEY ("id")
-);
-
--- CreateTable
-CREATE TABLE "permissions" (
- "id" VARCHAR NOT NULL,
- "workspace_id" VARCHAR NOT NULL,
- "user_id" VARCHAR,
- "user_email" TEXT,
- "type" SMALLINT NOT NULL,
- "accepted" BOOLEAN NOT NULL DEFAULT false,
- "created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
-
- CONSTRAINT "permissions_pkey" PRIMARY KEY ("id")
-);
-
--- CreateTable
-CREATE TABLE "seaql_migrations" (
- "version" VARCHAR NOT NULL,
- "applied_at" BIGINT NOT NULL,
-
- CONSTRAINT "seaql_migrations_pkey" PRIMARY KEY ("version")
-);
-
--- CreateTable
-CREATE TABLE "users" (
- "id" VARCHAR NOT NULL,
- "name" VARCHAR NOT NULL,
- "email" VARCHAR NOT NULL,
- "avatar_url" VARCHAR,
- "token_nonce" SMALLINT DEFAULT 0,
- "password" VARCHAR,
- "created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
-
- CONSTRAINT "users_pkey" PRIMARY KEY ("id")
-);
-
--- CreateTable
-CREATE TABLE "workspaces" (
- "id" VARCHAR NOT NULL,
- "public" BOOLEAN NOT NULL,
- "type" SMALLINT NOT NULL,
- "created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
-
- CONSTRAINT "workspaces_pkey" PRIMARY KEY ("id")
-);
-
--- CreateIndex
-CREATE UNIQUE INDEX "google_users_google_id_key" ON "google_users"("google_id");
-
--- CreateIndex
-CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-
--- AddForeignKey
-ALTER TABLE "google_users" ADD CONSTRAINT "google_users_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-
--- AddForeignKey
-ALTER TABLE "permissions" ADD CONSTRAINT "permissions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-
--- AddForeignKey
-ALTER TABLE "permissions" ADD CONSTRAINT "permissions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/apps/server/migrations/20230425035217_init/migration.sql b/apps/server/migrations/20230425035217_init/migration.sql
new file mode 100644
index 000000000..cbe1d2b56
--- /dev/null
+++ b/apps/server/migrations/20230425035217_init/migration.sql
@@ -0,0 +1,59 @@
+-- CreateTable
+CREATE TABLE "users" (
+ "id" VARCHAR NOT NULL,
+ "name" VARCHAR NOT NULL,
+ "email" VARCHAR NOT NULL,
+ "token_nonce" SMALLINT NOT NULL DEFAULT 0,
+ "avatar_url" VARCHAR,
+ "password" VARCHAR,
+ "fulfilled" BOOLEAN NOT NULL DEFAULT true,
+ "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "users_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "workspaces" (
+ "id" VARCHAR NOT NULL,
+ "public" BOOLEAN NOT NULL,
+ "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "workspaces_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "connected_accounts" (
+ "id" VARCHAR NOT NULL,
+ "user_id" TEXT NOT NULL,
+ "provider" VARCHAR NOT NULL,
+ "provider_user_id" VARCHAR NOT NULL,
+
+ CONSTRAINT "connected_accounts_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "user_workspace_permissions" (
+ "id" VARCHAR NOT NULL,
+ "workspace_id" VARCHAR NOT NULL,
+ "entity_id" VARCHAR NOT NULL,
+ "type" SMALLINT NOT NULL,
+ "accepted" BOOLEAN NOT NULL DEFAULT false,
+ "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "user_workspace_permissions_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "connected_accounts_provider_user_id_key" ON "connected_accounts"("provider_user_id");
+
+-- AddForeignKey
+ALTER TABLE "connected_accounts" ADD CONSTRAINT "connected_accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "user_workspace_permissions" ADD CONSTRAINT "user_workspace_permissions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "user_workspace_permissions" ADD CONSTRAINT "user_workspace_permissions_entity_id_fkey" FOREIGN KEY ("entity_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/apps/server/package.json b/apps/server/package.json
index 77927ed12..b9b80edf3 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -10,7 +10,8 @@
"scripts": {
"dev": "nodemon ./src/index.ts",
"test": "yarn exec ts-node-esm ./scripts/run-test.ts all",
- "test:coverage": "c8 yarn exec ts-node-esm ./scripts/run-test.ts all"
+ "test:coverage": "c8 yarn exec ts-node-esm ./scripts/run-test.ts all",
+ "postinstall": "prisma generate"
},
"dependencies": {
"@apollo/server": "^4.7.0",
@@ -21,8 +22,10 @@
"@nestjs/platform-express": "^9.4.0",
"@prisma/client": "^4.13.0",
"dotenv": "^16.0.3",
+ "express": "^4.18.2",
"graphql": "^16.6.0",
"graphql-type-json": "^0.3.2",
+ "jsonwebtoken": "^9.0.0",
"lodash-es": "^4.17.21",
"prisma": "^4.13.0",
"reflect-metadata": "^0.1.13",
@@ -30,6 +33,8 @@
},
"devDependencies": {
"@nestjs/testing": "^9.4.0",
+ "@types/express": "^4.17.17",
+ "@types/jsonwebtoken": "^9.0.1",
"@types/lodash-es": "^4.17.7",
"@types/node": "^18.16.2",
"@types/supertest": "^2.0.12",
diff --git a/apps/server/schema.prisma b/apps/server/schema.prisma
index 6c2d31ff7..290774c80 100644
--- a/apps/server/schema.prisma
+++ b/apps/server/schema.prisma
@@ -7,46 +7,57 @@ generator client {
provider = "prisma-client-js"
}
-model google_users {
- id String @id @db.VarChar
- user_id String @db.VarChar
- google_id String @unique @db.VarChar
- users users @relation(fields: [user_id], references: [id], onDelete: Cascade)
+model User {
+ id String @id @default(uuid()) @db.VarChar
+ name String @db.VarChar
+ 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 permissions {
- id String @id @db.VarChar
- workspace_id String @db.VarChar
- user_id String? @db.VarChar
- user_email String?
- type Int @db.SmallInt
- accepted Boolean @default(false)
- created_at DateTime? @default(now()) @db.Timestamptz(6)
- users users? @relation(fields: [user_id], references: [id], onDelete: Cascade)
- workspaces workspaces @relation(fields: [workspace_id], references: [id], onDelete: Cascade)
+model Workspace {
+ id String @id @default(uuid()) @db.VarChar
+ public Boolean
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
+ users UserWorkspacePermission[]
+
+ @@map("workspaces")
}
-model seaql_migrations {
- version String @id @db.VarChar
- applied_at BigInt
+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 users {
- id String @id @db.VarChar
- name String @db.VarChar
- email String @unique @db.VarChar
- avatar_url String? @db.VarChar
- token_nonce Int? @default(0) @db.SmallInt
- password String? @db.VarChar
- created_at DateTime? @default(now()) @db.Timestamptz(6)
- google_users google_users[]
- permissions permissions[]
-}
+model UserWorkspacePermission {
+ id String @id @default(uuid()) @db.VarChar
+ workspaceId String @map("workspace_id") @db.VarChar
+ userId String @map("entity_id") @db.VarChar
+ /// Read/Write/Admin/Owner
+ type Int @db.SmallInt
+ /// Whether the permission invitation is accepted by the user
+ accepted Boolean @default(false)
+ 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)
-model workspaces {
- id String @id @db.VarChar
- public Boolean
- type Int @db.SmallInt
- created_at DateTime? @default(now()) @db.Timestamptz(6)
- permissions permissions[]
+ @@map("user_workspace_permissions")
}
diff --git a/apps/server/scripts/init-db.ts b/apps/server/scripts/init-db.ts
index bc3bf73f6..3bb8bcd66 100644
--- a/apps/server/scripts/init-db.ts
+++ b/apps/server/scripts/init-db.ts
@@ -1,15 +1,10 @@
-import { randomUUID } from 'node:crypto';
-
import userA from '@affine-test/fixtures/userA.json' assert { type: 'json' };
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
- await prisma.users.create({
- data: {
- id: randomUUID(),
- ...userA,
- },
+ await prisma.user.create({
+ data: userA,
});
}
diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts
index 0a3bec64d..576befd38 100644
--- a/apps/server/src/app.ts
+++ b/apps/server/src/app.ts
@@ -1,3 +1,4 @@
+///
import { Module } from '@nestjs/common';
import { ConfigModule } from './config';
diff --git a/apps/server/src/config/def.ts b/apps/server/src/config/def.ts
index 19e2f6b36..404a458b2 100644
--- a/apps/server/src/config/def.ts
+++ b/apps/server/src/config/def.ts
@@ -69,6 +69,10 @@ export function parseEnvValue(value: string | undefined, type?: EnvConfigType) {
*/
export interface AFFiNEConfig {
ENV_MAP: Record;
+ /**
+ * Application sign key secret
+ */
+ readonly secret: string;
/**
* System version
*/
diff --git a/apps/server/src/config/default.ts b/apps/server/src/config/default.ts
index 1e186a775..9bdf1531b 100644
--- a/apps/server/src/config/default.ts
+++ b/apps/server/src/config/default.ts
@@ -2,6 +2,7 @@ import pkg from '../../package.json' assert { type: 'json' };
import type { AFFiNEConfig } from './def';
export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => ({
+ secret: 'secret',
version: pkg.version,
ENV_MAP: {},
env: process.env.NODE_ENV ?? 'development',
diff --git a/apps/server/src/global.d.ts b/apps/server/src/global.d.ts
new file mode 100644
index 000000000..0d2d7c791
--- /dev/null
+++ b/apps/server/src/global.d.ts
@@ -0,0 +1,5 @@
+declare namespace Express {
+ interface Request {
+ user?: import('@prisma/client').User | null;
+ }
+}
diff --git a/apps/server/src/modules/auth/guard.ts b/apps/server/src/modules/auth/guard.ts
new file mode 100644
index 000000000..1b70fb570
--- /dev/null
+++ b/apps/server/src/modules/auth/guard.ts
@@ -0,0 +1,82 @@
+import {
+ CanActivate,
+ createParamDecorator,
+ ExecutionContext,
+ Injectable,
+ UseGuards,
+} from '@nestjs/common';
+
+import { PrismaService } from '../../prisma';
+import { getRequestResponseFromContext } from '../../utils/nestjs';
+import { AuthService } from './service';
+
+export function getUserFromContext(context: ExecutionContext) {
+ const req = getRequestResponseFromContext(context).req;
+ return req.user;
+}
+
+/**
+ * Used to fetch current user from the request context.
+ *
+ * > The user may be undefined if authorization token is not provided.
+ *
+ * @example
+ *
+ * ```typescript
+ * // Graphql Query
+ * \@Query(() => UserType)
+ * user(@CurrentUser() user?: User) {
+ * return user;
+ * }
+ * ```
+ *
+ * ```typescript
+ * // HTTP Controller
+ * \@Get('/user)
+ * user(@CurrentUser() user?: User) {
+ * return user;
+ * }
+ * ```
+ */
+export const CurrentUser = createParamDecorator(
+ (_: unknown, context: ExecutionContext) => {
+ return getUserFromContext(context);
+ }
+);
+
+@Injectable()
+class AuthGuard implements CanActivate {
+ constructor(private auth: AuthService, private prisma: PrismaService) {}
+
+ async canActivate(context: ExecutionContext) {
+ const { req } = getRequestResponseFromContext(context);
+ const token = req.headers.authorization;
+ if (!token) {
+ return false;
+ }
+
+ const claims = this.auth.verify(token);
+ req.user = await this.prisma.user.findUnique({ where: { id: claims.id } });
+ return !!req.user;
+ }
+}
+
+/**
+ * This guard is used to protect routes/queries/mutations that require a user to be logged in.
+ *
+ * The `@CurrentUser()` parameter decorator used in a `Auth` guarded queries would always give us the user because the `Auth` guard will
+ * fast throw if user is not logged in.
+ *
+ * @example
+ *
+ * ```typescript
+ * \@Auth()
+ * \@Query(() => UserType)
+ * user(@CurrentUser() user: User) {
+ * return user;
+ * }
+ * ```
+ */
+export const Auth = () => {
+ return UseGuards(AuthGuard);
+};
diff --git a/apps/server/src/modules/auth/index.ts b/apps/server/src/modules/auth/index.ts
new file mode 100644
index 000000000..64badfe80
--- /dev/null
+++ b/apps/server/src/modules/auth/index.ts
@@ -0,0 +1,12 @@
+import { Global, Module } from '@nestjs/common';
+
+import { AuthResolver } from './resolver';
+import { AuthService } from './service';
+
+@Global()
+@Module({
+ providers: [AuthService, AuthResolver],
+ exports: [AuthService],
+})
+export class AuthModule {}
+export * from './guard';
diff --git a/apps/server/src/modules/auth/resolver.ts b/apps/server/src/modules/auth/resolver.ts
new file mode 100644
index 000000000..8c1e1aada
--- /dev/null
+++ b/apps/server/src/modules/auth/resolver.ts
@@ -0,0 +1,54 @@
+import { ForbiddenException } from '@nestjs/common';
+import {
+ Args,
+ Context,
+ Field,
+ Mutation,
+ ObjectType,
+ Parent,
+ ResolveField,
+ Resolver,
+} from '@nestjs/graphql';
+import { Request } from 'express';
+
+import { UserType } from '../users/resolver';
+import { CurrentUser } from './guard';
+import { AuthService } from './service';
+
+@ObjectType()
+export class TokenType {
+ @Field()
+ token!: string;
+
+ @Field()
+ refresh!: string;
+}
+
+@Resolver(() => UserType)
+export class AuthResolver {
+ constructor(private auth: AuthService) {}
+
+ @ResolveField(() => TokenType)
+ token(@CurrentUser() currentUser: UserType, @Parent() user: UserType) {
+ if (user !== currentUser) {
+ throw new ForbiddenException();
+ }
+
+ return {
+ token: this.auth.sign(user),
+ // TODO: impl
+ refresh: '',
+ };
+ }
+
+ @Mutation(() => UserType)
+ async signIn(
+ @Context() ctx: { req: Request },
+ @Args('email') email: string,
+ @Args('password') password: string
+ ) {
+ const user = await this.auth.signIn(email, password);
+ ctx.req.user = user;
+ return user;
+ }
+}
diff --git a/apps/server/src/modules/auth/service.ts b/apps/server/src/modules/auth/service.ts
new file mode 100644
index 000000000..24ec6de7b
--- /dev/null
+++ b/apps/server/src/modules/auth/service.ts
@@ -0,0 +1,45 @@
+import {
+ BadRequestException,
+ Injectable,
+ UnauthorizedException,
+} from '@nestjs/common';
+import { User } from '@prisma/client';
+import jwt from 'jsonwebtoken';
+
+import { Config } from '../../config';
+import { PrismaService } from '../../prisma';
+
+type UserClaim = Pick;
+
+@Injectable()
+export class AuthService {
+ constructor(private config: Config, private prisma: PrismaService) {}
+
+ sign(user: UserClaim) {
+ return jwt.sign(user, this.config.secret);
+ }
+
+ verify(token: string) {
+ try {
+ const claims = jwt.verify(token, this.config.secret) as UserClaim;
+ return claims;
+ } catch (e) {
+ throw new UnauthorizedException('Invalid token');
+ }
+ }
+
+ async signIn(email: string, password: string) {
+ const user = await this.prisma.user.findFirst({
+ where: {
+ email,
+ password,
+ },
+ });
+
+ if (!user) {
+ throw new BadRequestException('Invalid email or password');
+ }
+
+ return user;
+ }
+}
diff --git a/apps/server/src/modules/index.ts b/apps/server/src/modules/index.ts
index cd5cde7ce..1daa68cc3 100644
--- a/apps/server/src/modules/index.ts
+++ b/apps/server/src/modules/index.ts
@@ -1,4 +1,5 @@
+import { AuthModule } from './auth';
import { UsersModule } from './users';
import { WorkspaceModule } from './workspaces';
-export const BusinessModules = [WorkspaceModule, UsersModule];
+export const BusinessModules = [AuthModule, WorkspaceModule, UsersModule];
diff --git a/apps/server/src/modules/users/resolver.ts b/apps/server/src/modules/users/resolver.ts
index c92114acd..108b6549e 100644
--- a/apps/server/src/modules/users/resolver.ts
+++ b/apps/server/src/modules/users/resolver.ts
@@ -1,36 +1,36 @@
import { Args, Field, ID, ObjectType, Query, Resolver } from '@nestjs/graphql';
-import type { users } from '@prisma/client';
+import type { User } from '@prisma/client';
import { PrismaService } from '../../prisma/service';
@ObjectType()
-export class User implements users {
+export class UserType implements Partial {
@Field(() => ID)
id!: string;
+
@Field({ description: 'User name' })
name!: string;
+
@Field({ description: 'User email' })
email!: string;
- @Field({ description: 'User password', nullable: true })
- password!: string;
+
@Field({ description: 'User avatar url', nullable: true })
- avatar_url!: string;
- @Field({ description: 'User token nonce', nullable: true })
- token_nonce!: number;
+ avatarUrl!: string;
+
@Field({ description: 'User created date', nullable: true })
- created_at!: Date;
+ createdAt!: Date;
}
-@Resolver(() => User)
+@Resolver(() => UserType)
export class UserResolver {
constructor(private readonly prisma: PrismaService) {}
- @Query(() => User, {
+ @Query(() => UserType, {
name: 'user',
description: 'Get user by email',
})
async user(@Args('email') email: string) {
- return this.prisma.users.findUnique({
+ return this.prisma.user.findUnique({
where: { email },
});
}
diff --git a/apps/server/src/modules/workspaces/index.ts b/apps/server/src/modules/workspaces/index.ts
index b60c99ab4..910093675 100644
--- a/apps/server/src/modules/workspaces/index.ts
+++ b/apps/server/src/modules/workspaces/index.ts
@@ -1,8 +1,11 @@
import { Module } from '@nestjs/common';
+import { PermissionService } from './permission';
import { WorkspaceResolver } from './resolver';
@Module({
- providers: [WorkspaceResolver],
+ providers: [WorkspaceResolver, PermissionService],
+ exports: [PermissionService],
})
export class WorkspaceModule {}
+export { WorkspaceType } from './resolver';
diff --git a/apps/server/src/modules/workspaces/permission.ts b/apps/server/src/modules/workspaces/permission.ts
new file mode 100644
index 000000000..5a11e4013
--- /dev/null
+++ b/apps/server/src/modules/workspaces/permission.ts
@@ -0,0 +1,134 @@
+import { ForbiddenException, Injectable } from '@nestjs/common';
+import { Prisma } from '@prisma/client';
+
+import { PrismaService } from '../../prisma';
+import { Permission } from './types';
+
+@Injectable()
+export class PermissionService {
+ constructor(private readonly prisma: PrismaService) {}
+
+ async get(ws: string, user: string) {
+ const data = await this.prisma.userWorkspacePermission.findFirst({
+ where: {
+ workspaceId: ws,
+ userId: user,
+ accepted: true,
+ },
+ });
+
+ return data?.type as Permission;
+ }
+
+ async check(
+ ws: string,
+ user: string,
+ permission: Permission = Permission.Read
+ ) {
+ if (!(await this.tryCheck(ws, user, permission))) {
+ throw new ForbiddenException('Permission denied');
+ }
+ }
+
+ async tryCheck(
+ ws: string,
+ user: string,
+ permission: Permission = Permission.Read
+ ) {
+ const data = await this.prisma.userWorkspacePermission.count({
+ where: {
+ workspaceId: ws,
+ userId: user,
+ accepted: true,
+ type: {
+ gte: permission,
+ },
+ },
+ });
+
+ if (data > 0) {
+ return true;
+ }
+
+ // If the permission is read, we should check if the workspace is public
+ if (permission === Permission.Read) {
+ const data = await this.prisma.workspace.count({
+ where: { id: ws, public: true },
+ });
+
+ return data > 0;
+ }
+
+ return false;
+ }
+
+ async grant(
+ ws: string,
+ user: string,
+ permission: Permission = Permission.Read
+ ) {
+ const data = await this.prisma.userWorkspacePermission.findFirst({
+ where: {
+ workspaceId: ws,
+ userId: user,
+ accepted: true,
+ },
+ });
+
+ if (data) {
+ const [p] = await this.prisma.$transaction(
+ [
+ this.prisma.userWorkspacePermission.update({
+ where: {
+ id: data.id,
+ },
+ data: {
+ type: permission,
+ },
+ }),
+
+ // If the new permission is owner, we need to revoke old owner
+ permission === Permission.Owner
+ ? this.prisma.userWorkspacePermission.updateMany({
+ where: {
+ workspaceId: ws,
+ type: Permission.Owner,
+ userId: {
+ not: user,
+ },
+ },
+ data: {
+ type: Permission.Admin,
+ },
+ })
+ : null,
+ ].filter(Boolean) as Prisma.PrismaPromise[]
+ );
+
+ return p;
+ }
+
+ return this.prisma.userWorkspacePermission.create({
+ data: {
+ workspaceId: ws,
+ userId: user,
+ type: permission,
+ },
+ });
+ }
+
+ async revoke(ws: string, user: string) {
+ const result = await this.prisma.userWorkspacePermission.deleteMany({
+ where: {
+ workspaceId: ws,
+ userId: user,
+ type: {
+ // We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading
+ not: Permission.Owner,
+ },
+ },
+ });
+
+ return result.count > 0;
+ }
+}
diff --git a/apps/server/src/modules/workspaces/resolver.ts b/apps/server/src/modules/workspaces/resolver.ts
index 12f7590a1..70df93914 100644
--- a/apps/server/src/modules/workspaces/resolver.ts
+++ b/apps/server/src/modules/workspaces/resolver.ts
@@ -1,85 +1,206 @@
-import { randomUUID } from 'node:crypto';
-
+import { ForbiddenException, NotFoundException } from '@nestjs/common';
import {
Args,
Field,
ID,
+ InputType,
+ Int,
Mutation,
ObjectType,
+ Parent,
+ PartialType,
+ PickType,
Query,
registerEnumType,
+ ResolveField,
Resolver,
} from '@nestjs/graphql';
-import type { workspaces } from '@prisma/client';
+import type { User, Workspace } from '@prisma/client';
-import { PrismaService } from '../../prisma/service';
+import { PrismaService } from '../../prisma';
+import { Auth, CurrentUser } from '../auth';
+import { UserType } from '../users/resolver';
+import { PermissionService } from './permission';
+import { Permission } from './types';
-export enum WorkspaceType {
- Private = 0,
- Normal = 1,
-}
-
-registerEnumType(WorkspaceType, {
- name: 'WorkspaceType',
- description: 'Workspace type',
- valuesMap: {
- Normal: {
- description: 'Normal workspace',
- },
- Private: {
- description: 'Private workspace',
- },
- },
+registerEnumType(Permission, {
+ name: 'Permission',
+ description: 'User permission in workspace',
});
@ObjectType()
-export class Workspace implements workspaces {
+export class WorkspaceType implements Partial {
@Field(() => ID)
id!: string;
+
@Field({ description: 'is Public workspace' })
public!: boolean;
- @Field(() => WorkspaceType, { description: 'Workspace type' })
- type!: WorkspaceType;
+
@Field({ description: 'Workspace created date' })
- created_at!: Date;
+ createdAt!: Date;
}
-@Resolver(() => Workspace)
+@InputType()
+export class UpdateWorkspaceInput extends PickType(
+ PartialType(WorkspaceType),
+ ['public'],
+ InputType
+) {
+ @Field(() => ID)
+ id!: string;
+}
+
+@Auth()
+@Resolver(() => WorkspaceType)
export class WorkspaceResolver {
- constructor(private readonly prisma: PrismaService) {}
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly permissionProvider: PermissionService
+ ) {}
- // debug only query should be removed
- @Query(() => [Workspace], {
- name: 'workspaces',
- description: 'Get all workspaces',
+ @ResolveField(() => Permission, {
+ description: 'Permission of current signed in user in workspace',
+ complexity: 2,
})
- async workspaces() {
- return this.prisma.workspaces.findMany();
+ async permission(
+ @CurrentUser() user: User,
+ @Parent() workspace: WorkspaceType
+ ) {
+ // may applied in workspaces query
+ if ('permission' in workspace) {
+ return workspace.permission;
+ }
+
+ const permission = this.permissionProvider.get(workspace.id, user.id);
+
+ if (!permission) {
+ throw new ForbiddenException();
+ }
+
+ return permission;
}
- @Query(() => Workspace, {
- name: 'workspace',
- description: 'Get workspace by id',
+ @ResolveField(() => Int, {
+ description: 'member count of workspace',
+ complexity: 2,
})
- async workspace(@Args('id') id: string) {
- return this.prisma.workspaces.findUnique({
- where: { id },
- });
- }
-
- // create workspace
- @Mutation(() => Workspace, {
- name: 'createWorkspace',
- description: 'Create workspace',
- })
- async createWorkspace() {
- return this.prisma.workspaces.create({
- data: {
- id: randomUUID(),
- type: WorkspaceType.Private,
- public: false,
- created_at: new Date(),
+ memberCount(@Parent() workspace: WorkspaceType) {
+ return this.prisma.userWorkspacePermission.count({
+ where: {
+ workspaceId: workspace.id,
+ accepted: true,
},
});
}
+
+ @ResolveField(() => UserType, {
+ description: 'Owner of workspace',
+ complexity: 2,
+ })
+ async owner(@Parent() workspace: WorkspaceType) {
+ const data = await this.prisma.userWorkspacePermission.findFirstOrThrow({
+ where: {
+ workspaceId: workspace.id,
+ type: Permission.Owner,
+ },
+ include: {
+ user: true,
+ },
+ });
+
+ return data.user;
+ }
+
+ @Query(() => [WorkspaceType], {
+ description: 'Get all accessible workspaces for current user',
+ complexity: 2,
+ })
+ async workspaces(@CurrentUser() user: User) {
+ const data = await this.prisma.userWorkspacePermission.findMany({
+ where: {
+ userId: user.id,
+ accepted: true,
+ },
+ include: {
+ workspace: true,
+ },
+ });
+
+ return data.map(({ workspace, type }) => {
+ return {
+ ...workspace,
+ permission: type,
+ };
+ });
+ }
+
+ @Query(() => WorkspaceType, {
+ description: 'Get workspace by id',
+ })
+ async workspace(@CurrentUser() user: UserType, @Args('id') id: string) {
+ await this.permissionProvider.check(id, user.id);
+ const workspace = await this.prisma.workspace.findUnique({ where: { id } });
+
+ if (!workspace) {
+ throw new NotFoundException("Workspace doesn't exist");
+ }
+
+ return workspace;
+ }
+
+ @Mutation(() => WorkspaceType, {
+ description: 'Create a new workspace',
+ })
+ async createWorkspace(@CurrentUser() user: User) {
+ return this.prisma.workspace.create({
+ data: {
+ public: false,
+ users: {
+ create: {
+ type: Permission.Owner,
+ user: {
+ connect: {
+ id: user.id,
+ },
+ },
+ accepted: true,
+ },
+ },
+ },
+ });
+ }
+
+ @Mutation(() => WorkspaceType, {
+ description: 'Update workspace',
+ })
+ async updateWorkspace(
+ @CurrentUser() user: User,
+ @Args({ name: 'input', type: () => UpdateWorkspaceInput })
+ { id, ...updates }: UpdateWorkspaceInput
+ ) {
+ await this.permissionProvider.check('id', user.id, Permission.Admin);
+
+ return this.prisma.workspace.update({
+ where: {
+ id,
+ },
+ data: updates,
+ });
+ }
+
+ @Mutation(() => Boolean)
+ async deleteWorkspace(@CurrentUser() user: User, @Args('id') id: string) {
+ await this.permissionProvider.check(id, user.id, Permission.Owner);
+
+ await this.prisma.workspace.delete({
+ where: {
+ id,
+ },
+ });
+
+ // TODO:
+ // delete all related data, like websocket connections, blobs, etc.
+
+ return true;
+ }
}
diff --git a/apps/server/src/modules/workspaces/types.ts b/apps/server/src/modules/workspaces/types.ts
new file mode 100644
index 000000000..a86fcb6c2
--- /dev/null
+++ b/apps/server/src/modules/workspaces/types.ts
@@ -0,0 +1,6 @@
+export enum Permission {
+ Read = 0,
+ Write = 1,
+ Admin = 10,
+ Owner = 99,
+}
diff --git a/apps/server/src/prisma/index.ts b/apps/server/src/prisma/index.ts
index 37d50415b..b3f3b330d 100644
--- a/apps/server/src/prisma/index.ts
+++ b/apps/server/src/prisma/index.ts
@@ -8,3 +8,4 @@ import { PrismaService } from './service';
exports: [PrismaService],
})
export class PrismaModule {}
+export { PrismaService } from './service';
diff --git a/apps/server/src/tests/app.e2e.ts b/apps/server/src/tests/app.e2e.ts
index 965cb4e5c..b9e8485f8 100644
--- a/apps/server/src/tests/app.e2e.ts
+++ b/apps/server/src/tests/app.e2e.ts
@@ -34,22 +34,46 @@ describe('AppModule', () => {
.post(gql)
.send({
query: `
- query {
- error
- }
- `,
+ query {
+ error
+ }
+ `,
})
.expect(400);
+
+ let token;
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())
+ .post(gql)
+ .set({ Authorization: token })
.send({
query: `
mutation {
createWorkspace {
id
- type
public
- created_at
+ createdAt
}
}
`,
@@ -64,16 +88,12 @@ describe('AppModule', () => {
typeof res.body.data.createWorkspace.id === 'string',
'res.body.data.createWorkspace.id is not a string'
);
- ok(
- typeof res.body.data.createWorkspace.type === 'string',
- 'res.body.data.createWorkspace.type is not a string'
- );
ok(
typeof res.body.data.createWorkspace.public === 'boolean',
'res.body.data.createWorkspace.public is not a boolean'
);
ok(
- typeof res.body.data.createWorkspace.created_at === 'string',
+ typeof res.body.data.createWorkspace.createdAt === 'string',
'res.body.data.createWorkspace.created_at is not a string'
);
});
@@ -87,7 +107,7 @@ describe('AppModule', () => {
query {
user(email: "alex.yang@example.org") {
email
- avatar_url
+ avatarUrl
}
}
`,
diff --git a/apps/server/src/utils/nestjs.ts b/apps/server/src/utils/nestjs.ts
new file mode 100644
index 000000000..f1f18beef
--- /dev/null
+++ b/apps/server/src/utils/nestjs.ts
@@ -0,0 +1,57 @@
+import { ArgumentsHost, ExecutionContext } from '@nestjs/common';
+import {
+ GqlArgumentsHost,
+ GqlContextType,
+ GqlExecutionContext,
+} from '@nestjs/graphql';
+import { Request, Response } from 'express';
+
+export function getRequestResponseFromContext(context: ExecutionContext) {
+ switch (context.getType()) {
+ case 'graphql': {
+ const gqlContext = GqlExecutionContext.create(context).getContext<{
+ req: Request;
+ }>();
+ return {
+ req: gqlContext.req,
+ res: gqlContext.req.res!,
+ };
+ }
+ case 'http': {
+ const http = context.switchToHttp();
+ return {
+ req: http.getRequest(),
+ res: http.getResponse(),
+ };
+ }
+ default:
+ throw new Error('Unknown context type for getting request and response');
+ }
+}
+
+export function getRequestResponseFromHost(host: ArgumentsHost) {
+ switch (host.getType()) {
+ case 'graphql': {
+ const gqlContext = GqlArgumentsHost.create(host).getContext<{
+ req: Request;
+ }>();
+ return {
+ req: gqlContext.req,
+ res: gqlContext.req.res!,
+ };
+ }
+ case 'http': {
+ const http = host.switchToHttp();
+ return {
+ req: http.getRequest(),
+ res: http.getResponse(),
+ };
+ }
+ default:
+ throw new Error('Unknown host type for getting request and response');
+ }
+}
+
+export function getRequestFromHost(host: ArgumentsHost) {
+ return getRequestResponseFromHost(host).req;
+}
diff --git a/apps/server/tsconfig.node.json b/apps/server/tsconfig.node.json
index b9e486d42..4651f71bc 100644
--- a/apps/server/tsconfig.node.json
+++ b/apps/server/tsconfig.node.json
@@ -7,5 +7,5 @@
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
- "include": ["./scripts", "package.json"]
+ "include": ["scripts", "package.json"]
}
diff --git a/tsconfig.json b/tsconfig.json
index 2fc98ec3d..a4f0c1bf8 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -46,6 +46,9 @@
{
"path": "./apps/web"
},
+ {
+ "path": "./apps/server"
+ },
{
"path": "./packages/component"
},
diff --git a/yarn.lock b/yarn.lock
index a5231f9ad..78abd649e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -248,13 +248,17 @@ __metadata:
"@nestjs/platform-express": ^9.4.0
"@nestjs/testing": ^9.4.0
"@prisma/client": ^4.13.0
+ "@types/express": ^4.17.17
+ "@types/jsonwebtoken": ^9.0.1
"@types/lodash-es": ^4.17.7
"@types/node": ^18.16.2
"@types/supertest": ^2.0.12
c8: ^7.13.0
dotenv: ^16.0.3
+ express: ^4.18.2
graphql: ^16.6.0
graphql-type-json: ^0.3.2
+ jsonwebtoken: ^9.0.0
lodash-es: ^4.17.21
nodemon: ^2.0.22
prisma: ^4.13.0
@@ -8269,7 +8273,7 @@ __metadata:
languageName: node
linkType: hard
-"@types/express@npm:^4.17.13, @types/express@npm:^4.7.0":
+"@types/express@npm:^4.17.13, @types/express@npm:^4.17.17, @types/express@npm:^4.7.0":
version: 4.17.17
resolution: "@types/express@npm:4.17.17"
dependencies:
@@ -8438,6 +8442,15 @@ __metadata:
languageName: node
linkType: hard
+"@types/jsonwebtoken@npm:^9.0.1":
+ version: 9.0.1
+ resolution: "@types/jsonwebtoken@npm:9.0.1"
+ dependencies:
+ "@types/node": "*"
+ checksum: a7f0925e9a42ad3ae970364c63c5986d40da5c83d51d3f4e624eb0f064a380376f9e3fb3f2f837390a9ab80143f5d75fd51866da30e110f6b486a3379e1c768f
+ languageName: node
+ linkType: hard
+
"@types/keyv@npm:^3.1.4":
version: 3.1.4
resolution: "@types/keyv@npm:3.1.4"
@@ -10609,6 +10622,13 @@ __metadata:
languageName: node
linkType: hard
+"buffer-equal-constant-time@npm:1.0.1":
+ version: 1.0.1
+ resolution: "buffer-equal-constant-time@npm:1.0.1"
+ checksum: 80bb945f5d782a56f374b292770901065bad21420e34936ecbe949e57724b4a13874f735850dd1cc61f078773c4fb5493a41391e7bda40d1fa388d6bd80daaab
+ languageName: node
+ linkType: hard
+
"buffer-equal@npm:^1.0.0":
version: 1.0.1
resolution: "buffer-equal@npm:1.0.1"
@@ -12575,6 +12595,15 @@ __metadata:
languageName: node
linkType: hard
+"ecdsa-sig-formatter@npm:1.0.11":
+ version: 1.0.11
+ resolution: "ecdsa-sig-formatter@npm:1.0.11"
+ dependencies:
+ safe-buffer: ^5.0.1
+ checksum: 207f9ab1c2669b8e65540bce29506134613dd5f122cccf1e6a560f4d63f2732d427d938f8481df175505aad94583bcb32c688737bb39a6df0625f903d6d93c03
+ languageName: node
+ linkType: hard
+
"ee-first@npm:1.1.1":
version: 1.1.1
resolution: "ee-first@npm:1.1.1"
@@ -13749,7 +13778,7 @@ __metadata:
languageName: node
linkType: hard
-"express@npm:4.18.2, express@npm:^4.17.1, express@npm:^4.17.3":
+"express@npm:4.18.2, express@npm:^4.17.1, express@npm:^4.17.3, express@npm:^4.18.2":
version: 4.18.2
resolution: "express@npm:4.18.2"
dependencies:
@@ -17588,6 +17617,18 @@ __metadata:
languageName: node
linkType: hard
+"jsonwebtoken@npm:^9.0.0":
+ version: 9.0.0
+ resolution: "jsonwebtoken@npm:9.0.0"
+ dependencies:
+ jws: ^3.2.2
+ lodash: ^4.17.21
+ ms: ^2.1.1
+ semver: ^7.3.8
+ checksum: b9181cecf9df99f1dc0253f91ba000a1aa4d91f5816d1608c0dba61a5623726a0bfe200b51df25de18c1a6000825d231ad7ce2788aa54fd48dcb760ad9eb9514
+ languageName: node
+ linkType: hard
+
"jsx-ast-utils@npm:^2.4.1 || ^3.0.0, jsx-ast-utils@npm:^3.3.3":
version: 3.3.3
resolution: "jsx-ast-utils@npm:3.3.3"
@@ -17605,6 +17646,27 @@ __metadata:
languageName: node
linkType: hard
+"jwa@npm:^1.4.1":
+ version: 1.4.1
+ resolution: "jwa@npm:1.4.1"
+ dependencies:
+ buffer-equal-constant-time: 1.0.1
+ ecdsa-sig-formatter: 1.0.11
+ safe-buffer: ^5.0.1
+ checksum: ff30ea7c2dcc61f3ed2098d868bf89d43701605090c5b21b5544b512843ec6fd9e028381a4dda466cbcdb885c2d1150f7c62e7168394ee07941b4098e1035e2f
+ languageName: node
+ linkType: hard
+
+"jws@npm:^3.2.2":
+ version: 3.2.2
+ resolution: "jws@npm:3.2.2"
+ dependencies:
+ jwa: ^1.4.1
+ safe-buffer: ^5.0.1
+ checksum: f0213fe5b79344c56cd443428d8f65c16bf842dc8cb8f5aed693e1e91d79c20741663ad6eff07a6d2c433d1831acc9814e8d7bada6a0471fbb91d09ceb2bf5c2
+ languageName: node
+ linkType: hard
+
"keyv@npm:^4.0.0, keyv@npm:^4.5.2":
version: 4.5.2
resolution: "keyv@npm:4.5.2"