mirror of
https://github.com/teableio/teable
synced 2024-11-21 23:04:16 +00:00
feat: integrate github login (#432)
* feat: integrate gitHub login * feat: dynamic social auth module * feat: add github auth env validation
This commit is contained in:
parent
a8e3442dc6
commit
61044b1c92
@ -69,6 +69,7 @@
|
||||
"@types/node-fetch": "2.6.11",
|
||||
"@types/nodemailer": "6.4.14",
|
||||
"@types/passport": "1.0.16",
|
||||
"@types/passport-github2": "1.2.9",
|
||||
"@types/passport-jwt": "4.0.1",
|
||||
"@types/passport-local": "1.0.38",
|
||||
"@types/pause": "0.1.3",
|
||||
@ -166,6 +167,7 @@
|
||||
"nodemailer": "6.9.11",
|
||||
"papaparse": "5.4.1",
|
||||
"passport": "0.7.0",
|
||||
"passport-github2": "0.1.12",
|
||||
"passport-jwt": "4.0.1",
|
||||
"passport-local": "1.0.0",
|
||||
"pause": "0.1.0",
|
||||
|
5
apps/nestjs-backend/src/cache/types.ts
vendored
5
apps/nestjs-backend/src/cache/types.ts
vendored
@ -9,6 +9,7 @@ export interface ICacheStore {
|
||||
[key: `auth:session-store:${string}`]: ISessionData;
|
||||
[key: `auth:session-user:${string}`]: Record<string, number>;
|
||||
[key: `auth:session-expire:${string}`]: boolean;
|
||||
[key: `oauth2:${string}`]: IOauth2State;
|
||||
}
|
||||
|
||||
export interface IAttachmentSignatureCache {
|
||||
@ -33,3 +34,7 @@ export interface IAttachmentPreviewCache {
|
||||
url: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
export interface IOauth2State {
|
||||
redirectUri?: string;
|
||||
}
|
||||
|
@ -22,6 +22,11 @@ export const authConfig = registerAs('auth', () => ({
|
||||
iv: process.env.BACKEND_ACCESS_TOKEN_ENCRYPTION_IV ?? 'i0vKGXBWkzyAoGf4',
|
||||
},
|
||||
},
|
||||
socialAuthProviders: process.env.SOCIAL_AUTH_PROVIDERS?.split(',') ?? [],
|
||||
github: {
|
||||
clientID: process.env.BACKEND_GITHUB_CLIENT_ID,
|
||||
clientSecret: process.env.BACKEND_GITHUB_CLIENT_SECRET,
|
||||
},
|
||||
}));
|
||||
|
||||
export const AuthConfig = () => Inject(authConfig.KEY);
|
||||
|
@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import Joi from 'joi';
|
||||
|
||||
export const envValidationSchema = Joi.object({
|
||||
@ -37,4 +38,23 @@ export const envValidationSchema = Joi.object({
|
||||
.pattern(/^(redis:\/\/|rediss:\/\/)/)
|
||||
.message('Cache `redis` the URI must start with the protocol `redis://` or `rediss://`'),
|
||||
}),
|
||||
// github auth
|
||||
BACKEND_GITHUB_CLIENT_ID: Joi.when('SOCIAL_AUTH_PROVIDERS', {
|
||||
is: Joi.string()
|
||||
.regex(/(^|,)(github)(,|$)/)
|
||||
.required(),
|
||||
then: Joi.string().required().messages({
|
||||
'any.required':
|
||||
'The `BACKEND_GITHUB_CLIENT_ID` is required when `SOCIAL_AUTH_PROVIDERS` includes `github`',
|
||||
}),
|
||||
}),
|
||||
BACKEND_GITHUB_CLIENT_SECRET: Joi.when('SOCIAL_AUTH_PROVIDERS', {
|
||||
is: Joi.string()
|
||||
.regex(/(^|,)(github)(,|$)/)
|
||||
.required(),
|
||||
then: Joi.string().required().messages({
|
||||
'any.required':
|
||||
'The `BACKEND_GITHUB_CLIENT_SECRET` is required when `SOCIAL_AUTH_PROVIDERS` includes `github`',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
@ -1,3 +1,4 @@
|
||||
import type { Readable as ReadableStream } from 'node:stream';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { UploadType } from '@teable/openapi';
|
||||
import { storageConfig } from '../../../configs/storage';
|
||||
@ -16,7 +17,7 @@ export default abstract class StorageAdapter {
|
||||
}
|
||||
};
|
||||
|
||||
static readonly getDir = (type: UploadType) => {
|
||||
static readonly getDir = (type: UploadType): string => {
|
||||
switch (type) {
|
||||
case UploadType.Table:
|
||||
return 'table';
|
||||
@ -79,7 +80,7 @@ export default abstract class StorageAdapter {
|
||||
path: string,
|
||||
filePath: string,
|
||||
metadata: Record<string, unknown>
|
||||
): Promise<string>;
|
||||
): Promise<{ hash: string; url: string }>;
|
||||
|
||||
/**
|
||||
* uploadFile with file stream
|
||||
@ -91,7 +92,7 @@ export default abstract class StorageAdapter {
|
||||
abstract uploadFile(
|
||||
bucket: string,
|
||||
path: string,
|
||||
stream: Buffer,
|
||||
stream: Buffer | ReadableStream,
|
||||
metadata?: Record<string, unknown>
|
||||
): Promise<string>;
|
||||
): Promise<{ hash: string; url: string }>;
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import { createReadStream, createWriteStream } from 'fs';
|
||||
import { join, resolve, dirname } from 'path';
|
||||
import { type Readable as ReadableStream } from 'node:stream';
|
||||
import { join, resolve } from 'path';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { getRandomString } from '@teable/core';
|
||||
import type { Request } from 'express';
|
||||
@ -8,6 +9,7 @@ import * as fse from 'fs-extra';
|
||||
import sharp from 'sharp';
|
||||
import { CacheService } from '../../../cache/cache.service';
|
||||
import { IStorageConfig, StorageConfig } from '../../../configs/storage';
|
||||
import { FileUtils } from '../../../utils';
|
||||
import { Encryptor } from '../../../utils/encryptor';
|
||||
import { getFullStorageUrl } from '../../../utils/full-storage-url';
|
||||
import { second } from '../../../utils/second';
|
||||
@ -229,22 +231,46 @@ export class LocalStorage implements StorageAdapter {
|
||||
filePath: string,
|
||||
_metadata: Record<string, unknown>
|
||||
) {
|
||||
this.save(filePath, join(bucket, path));
|
||||
return join(this.readPath, bucket, path);
|
||||
const hash = await FileUtils.getHash(filePath);
|
||||
await this.save(filePath, join(bucket, path));
|
||||
return {
|
||||
hash,
|
||||
url: join(this.readPath, bucket, path),
|
||||
};
|
||||
}
|
||||
|
||||
async uploadFile(
|
||||
bucket: string,
|
||||
path: string,
|
||||
stream: Buffer,
|
||||
stream: Buffer | ReadableStream,
|
||||
_metadata?: Record<string, unknown>
|
||||
): Promise<string> {
|
||||
const distPath = resolve(this.storageDir);
|
||||
const newFilePath = resolve(distPath, join(bucket, path));
|
||||
|
||||
await fse.ensureDir(dirname(newFilePath));
|
||||
|
||||
await fse.writeFile(newFilePath, stream);
|
||||
return join(this.readPath, bucket, path);
|
||||
) {
|
||||
const name = getRandomString(12);
|
||||
const temPath = resolve(this.temporaryDir, name);
|
||||
if (stream instanceof Buffer) {
|
||||
await fse.writeFile(temPath, stream);
|
||||
} else {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const writer = createWriteStream(temPath);
|
||||
stream.pipe(writer);
|
||||
stream.on('end', function () {
|
||||
writer.end();
|
||||
writer.close();
|
||||
resolve();
|
||||
});
|
||||
stream.on('error', (err) => {
|
||||
writer.end();
|
||||
writer.close();
|
||||
this.deleteFile(path);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
const hash = await FileUtils.getHash(temPath);
|
||||
await this.save(temPath, join(bucket, path));
|
||||
return {
|
||||
hash,
|
||||
url: join(this.readPath, bucket, path),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import type { Readable as ReadableStream } from 'node:stream';
|
||||
import { join } from 'path';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { getRandomString } from '@teable/core';
|
||||
@ -101,17 +102,23 @@ export class MinioStorage implements StorageAdapter {
|
||||
filePath: string,
|
||||
metadata: Record<string, unknown>
|
||||
) {
|
||||
await this.minioClient.fPutObject(bucket, path, filePath, metadata);
|
||||
return `/${bucket}/${path}`;
|
||||
const { etag: hash } = await this.minioClient.fPutObject(bucket, path, filePath, metadata);
|
||||
return {
|
||||
hash,
|
||||
url: `/${bucket}/${path}`,
|
||||
};
|
||||
}
|
||||
|
||||
async uploadFile(
|
||||
bucket: string,
|
||||
path: string,
|
||||
stream: Buffer,
|
||||
stream: Buffer | ReadableStream,
|
||||
metadata?: Record<string, unknown>
|
||||
): Promise<string> {
|
||||
await this.minioClient.putObject(bucket, path, stream, metadata);
|
||||
return `/${bucket}/${path}`;
|
||||
) {
|
||||
const { etag: hash } = await this.minioClient.putObject(bucket, path, stream, metadata);
|
||||
return {
|
||||
hash,
|
||||
url: `/${bucket}/${path}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ import { AuthGuard } from './guard/auth.guard';
|
||||
import { SessionStoreService } from './session/session-store.service';
|
||||
import { SessionModule } from './session/session.module';
|
||||
import { SessionSerializer } from './session/session.serializer';
|
||||
import { SocialModule } from './social/social.module';
|
||||
import { AccessTokenStrategy } from './strategies/access-token.strategy';
|
||||
import { LocalStrategy } from './strategies/local.strategy';
|
||||
import { SessionStrategy } from './strategies/session.strategy';
|
||||
@ -19,6 +20,7 @@ import { SessionStrategy } from './strategies/session.strategy';
|
||||
PassportModule.register({ session: true }),
|
||||
SessionModule,
|
||||
AccessTokenModule,
|
||||
SocialModule,
|
||||
],
|
||||
providers: [
|
||||
AuthService,
|
||||
|
@ -53,13 +53,15 @@ export class AuthService {
|
||||
throw new HttpException(`User ${email} is already registered`, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
const { salt, hashPassword } = await this.encodePassword(password);
|
||||
return await this.userService.createUser({
|
||||
id: generateUserId(),
|
||||
name: email.split('@')[0],
|
||||
email,
|
||||
salt,
|
||||
password: hashPassword,
|
||||
lastSignTime: new Date().toISOString(),
|
||||
return await this.prismaService.$tx(async () => {
|
||||
return await this.userService.createUser({
|
||||
id: generateUserId(),
|
||||
name: email.split('@')[0],
|
||||
email,
|
||||
salt,
|
||||
password: hashPassword,
|
||||
lastSignTime: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class GithubGuard extends AuthGuard('github') {}
|
39
apps/nestjs-backend/src/features/auth/oauth/oauth.store.ts
Normal file
39
apps/nestjs-backend/src/features/auth/oauth/oauth.store.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { getRandomString } from '@teable/core';
|
||||
import type { Request } from 'express';
|
||||
import { CacheService } from '../../../cache/cache.service';
|
||||
import type { IOauth2State } from '../../../cache/types';
|
||||
import { second } from '../../../utils/second';
|
||||
|
||||
@Injectable()
|
||||
export class OauthStoreService {
|
||||
key: string = 'oauth2:';
|
||||
|
||||
constructor(private readonly cacheService: CacheService) {}
|
||||
|
||||
async store(req: Request, callback: (err: unknown, stateId: string) => void) {
|
||||
const random = getRandomString(16);
|
||||
await this.cacheService.set(
|
||||
`oauth2:${random}`,
|
||||
{
|
||||
redirectUri: req.query.redirect_uri as string,
|
||||
},
|
||||
second('12h')
|
||||
);
|
||||
callback(null, random);
|
||||
}
|
||||
|
||||
async verify(
|
||||
_req: unknown,
|
||||
stateId: string,
|
||||
callback: (err: unknown, ok: boolean, state: IOauth2State | string) => void
|
||||
) {
|
||||
const state = await this.cacheService.get(`oauth2:${stateId}`);
|
||||
if (state) {
|
||||
await this.cacheService.del(`oauth2:${stateId}`);
|
||||
callback(null, true, state);
|
||||
} else {
|
||||
callback(null, false, 'Invalid authorization request state');
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import type { IOauth2State } from '../../../../cache/types';
|
||||
import { Public } from '../../decorators/public.decorator';
|
||||
import { GithubGuard } from '../../guard/github.guard';
|
||||
|
||||
@Controller('api/auth')
|
||||
export class GithubController {
|
||||
@Get('/github')
|
||||
@Public()
|
||||
@UseGuards(GithubGuard)
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
async githubAuthenticate() {}
|
||||
|
||||
@Get('/github/callback')
|
||||
@Public()
|
||||
@UseGuards(GithubGuard)
|
||||
async githubCallback(@Req() req: Express.Request, @Res({ passthrough: true }) res: Response) {
|
||||
const user = req.user!;
|
||||
// set cookie, passport login
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
req.login(user, (err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
const redirectUri = (req.authInfo as { state: IOauth2State })?.state?.redirectUri;
|
||||
return res.redirect(redirectUri || '/');
|
||||
}
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UserModule } from '../../../user/user.module';
|
||||
import { OauthStoreService } from '../../oauth/oauth.store';
|
||||
import { GithubStrategy } from '../../strategies/github.strategy';
|
||||
import { GithubController } from './github.controller';
|
||||
|
||||
@Module({
|
||||
imports: [UserModule],
|
||||
providers: [GithubStrategy, OauthStoreService],
|
||||
exports: [],
|
||||
controllers: [GithubController],
|
||||
})
|
||||
export class GithubModule {}
|
@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConditionalModule } from '@nestjs/config';
|
||||
import { GithubModule } from './github/github.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConditionalModule.registerWhen(GithubModule, (env) => {
|
||||
return Boolean(env.SOCIAL_AUTH_PROVIDERS?.split(',')?.includes('github'));
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class SocialModule {}
|
@ -0,0 +1,47 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigType } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import type { Profile } from 'passport-github2';
|
||||
import { Strategy } from 'passport-github2';
|
||||
import { AuthConfig } from '../../../configs/auth.config';
|
||||
import type { authConfig } from '../../../configs/auth.config';
|
||||
import { UserService } from '../../user/user.service';
|
||||
import { OauthStoreService } from '../oauth/oauth.store';
|
||||
import { pickUserMe } from '../utils';
|
||||
|
||||
@Injectable()
|
||||
export class GithubStrategy extends PassportStrategy(Strategy, 'github') {
|
||||
constructor(
|
||||
@AuthConfig() readonly config: ConfigType<typeof authConfig>,
|
||||
private usersService: UserService,
|
||||
oauthStoreService: OauthStoreService
|
||||
) {
|
||||
const { clientID, clientSecret } = config.github;
|
||||
super({
|
||||
clientID,
|
||||
clientSecret,
|
||||
state: true,
|
||||
store: oauthStoreService,
|
||||
});
|
||||
}
|
||||
|
||||
async validate(_accessToken: string, _refreshToken: string, profile: Profile) {
|
||||
const { id, emails, displayName, photos } = profile;
|
||||
const email = emails?.[0].value;
|
||||
if (!email) {
|
||||
throw new UnauthorizedException('No email provided from GitHub');
|
||||
}
|
||||
const user = await this.usersService.findOrCreateUser({
|
||||
name: displayName,
|
||||
email,
|
||||
provider: 'github',
|
||||
providerId: id,
|
||||
type: 'oauth',
|
||||
avatarUrl: photos?.[0].value,
|
||||
});
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Failed to create user from GitHub profile');
|
||||
}
|
||||
return pickUserMe(user);
|
||||
}
|
||||
}
|
@ -1,16 +1,21 @@
|
||||
import https from 'https';
|
||||
import { join } from 'path';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { generateSpaceId, minidenticon, SpaceRole } from '@teable/core';
|
||||
import {
|
||||
generateAccountId,
|
||||
generateSpaceId,
|
||||
generateUserId,
|
||||
minidenticon,
|
||||
SpaceRole,
|
||||
} from '@teable/core';
|
||||
import type { Prisma } from '@teable/db-main-prisma';
|
||||
import { PrismaService } from '@teable/db-main-prisma';
|
||||
import { type ICreateSpaceRo, type IUserNotifyMeta, UploadType } from '@teable/openapi';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import sharp from 'sharp';
|
||||
import type { IClsStore } from '../../types/cls';
|
||||
import { FileUtils } from '../../utils';
|
||||
import { getFullStorageUrl } from '../../utils/full-storage-url';
|
||||
import StorageAdapter from '../attachments/plugins/adapter';
|
||||
import { LocalStorage } from '../attachments/plugins/local';
|
||||
import { InjectStorageAdapter } from '../attachments/plugins/storage';
|
||||
|
||||
@Injectable()
|
||||
@ -67,7 +72,10 @@ export class UserService {
|
||||
return space;
|
||||
}
|
||||
|
||||
async createUser(user: Prisma.UserCreateInput) {
|
||||
async createUser(
|
||||
user: Prisma.UserCreateInput,
|
||||
account?: Omit<Prisma.AccountUncheckedCreateInput, 'userId'>
|
||||
) {
|
||||
// defaults
|
||||
const defaultNotifyMeta: IUserNotifyMeta = {
|
||||
email: true,
|
||||
@ -75,6 +83,7 @@ export class UserService {
|
||||
|
||||
user = {
|
||||
...user,
|
||||
id: user.id ?? generateUserId(),
|
||||
notifyMeta: JSON.stringify(defaultNotifyMeta),
|
||||
};
|
||||
|
||||
@ -85,17 +94,19 @@ export class UserService {
|
||||
avatar,
|
||||
};
|
||||
}
|
||||
|
||||
// default space created
|
||||
return await this.prismaService.$tx(async (prisma) => {
|
||||
const newUser = await prisma.user.create({ data: user });
|
||||
const { id, name } = newUser;
|
||||
await this.cls.runWith(this.cls.get(), async () => {
|
||||
this.cls.set('user.id', id);
|
||||
await this.createSpaceBySignup({ name: `${name}'s space` });
|
||||
const newUser = await this.prismaService.txClient().user.create({ data: user });
|
||||
const { id, name } = newUser;
|
||||
if (account) {
|
||||
await this.prismaService.txClient().account.create({
|
||||
data: { id: generateAccountId(), ...account, userId: id },
|
||||
});
|
||||
return newUser;
|
||||
}
|
||||
await this.cls.runWith(this.cls.get(), async () => {
|
||||
this.cls.set('user.id', id);
|
||||
await this.createSpaceBySignup({ name: `${name}'s space` });
|
||||
});
|
||||
return newUser;
|
||||
}
|
||||
|
||||
async updateUserName(id: string, name: string) {
|
||||
@ -107,29 +118,19 @@ export class UserService {
|
||||
});
|
||||
}
|
||||
|
||||
async updateAvatar(id: string, avatarFile: Express.Multer.File) {
|
||||
async updateAvatar(id: string, avatarFile: { path: string; mimetype: string; size: number }) {
|
||||
const path = join(StorageAdapter.getDir(UploadType.Avatar), id);
|
||||
const bucket = StorageAdapter.getBucket(UploadType.Avatar);
|
||||
const url = await this.storageAdapter.uploadFileWidthPath(bucket, path, avatarFile.path, {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'Content-Type': avatarFile.mimetype,
|
||||
});
|
||||
|
||||
const { size, mimetype, path: filePath } = avatarFile;
|
||||
let hash, width, height;
|
||||
|
||||
const storage = this.storageAdapter;
|
||||
if (storage instanceof LocalStorage) {
|
||||
hash = await FileUtils.getHash(filePath);
|
||||
const fileMate = await storage.getFileMate(filePath);
|
||||
width = fileMate.width;
|
||||
height = fileMate.height;
|
||||
} else {
|
||||
const objectMeta = await storage.getObjectMeta(bucket, path, id);
|
||||
hash = objectMeta.hash;
|
||||
width = objectMeta.width;
|
||||
height = objectMeta.height;
|
||||
}
|
||||
const { hash, url } = await this.storageAdapter.uploadFileWidthPath(
|
||||
bucket,
|
||||
path,
|
||||
avatarFile.path,
|
||||
{
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'Content-Type': avatarFile.mimetype,
|
||||
}
|
||||
);
|
||||
const { size, mimetype } = avatarFile;
|
||||
|
||||
await this.mountAttachment(id, {
|
||||
bucket,
|
||||
@ -138,8 +139,6 @@ export class UserService {
|
||||
mimetype,
|
||||
token: id,
|
||||
path,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
await this.prismaService.txClient().user.update({
|
||||
@ -186,24 +185,95 @@ export class UserService {
|
||||
.resize(svgSize[0], svgSize[1])
|
||||
.flatten({ background: '#f0f0f0' })
|
||||
.png({ quality: 90 });
|
||||
const mimetype = 'image/png';
|
||||
const { size } = await svgObject.metadata();
|
||||
const svgBuffer = await svgObject.toBuffer();
|
||||
const svgHash = await FileUtils.getHash(svgBuffer);
|
||||
|
||||
const { url, hash } = await this.storageAdapter.uploadFile(bucket, path, svgBuffer, {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'Content-Type': mimetype,
|
||||
});
|
||||
|
||||
await this.mountAttachment(id, {
|
||||
bucket: bucket,
|
||||
hash: svgHash,
|
||||
hash: hash,
|
||||
size: size,
|
||||
mimetype: 'image/png',
|
||||
mimetype: mimetype,
|
||||
token: id,
|
||||
path: path,
|
||||
width: svgSize[0],
|
||||
height: svgSize[1],
|
||||
});
|
||||
|
||||
return this.storageAdapter.uploadFile(bucket, path, svgBuffer, {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'Content-Type': 'image/png',
|
||||
return url;
|
||||
}
|
||||
|
||||
private async uploadAvatarByUrl(userId: string, url: string) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
https
|
||||
.get(url, async (stream) => {
|
||||
const contentType = stream?.headers?.['content-type']?.split(';')?.[0];
|
||||
const size = stream?.headers?.['content-length']?.split(';')?.[0];
|
||||
const path = join(StorageAdapter.getDir(UploadType.Avatar), userId);
|
||||
const bucket = StorageAdapter.getBucket(UploadType.Avatar);
|
||||
|
||||
const { url, hash } = await this.storageAdapter.uploadFile(bucket, path, stream, {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'Content-Type': contentType,
|
||||
});
|
||||
|
||||
await this.mountAttachment(userId, {
|
||||
bucket: bucket,
|
||||
hash: hash,
|
||||
size: size ? parseInt(size) : undefined,
|
||||
mimetype: contentType,
|
||||
token: userId,
|
||||
path: path,
|
||||
});
|
||||
resolve(url);
|
||||
})
|
||||
.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async findOrCreateUser(user: {
|
||||
name: string;
|
||||
email: string;
|
||||
provider: string;
|
||||
providerId: string;
|
||||
type: string;
|
||||
avatarUrl?: string;
|
||||
}) {
|
||||
return this.prismaService.$tx(async () => {
|
||||
const { email, name, provider, providerId, type, avatarUrl } = user;
|
||||
// account exist check
|
||||
const existAccount = await this.prismaService.txClient().account.findFirst({
|
||||
where: { provider, providerId },
|
||||
});
|
||||
if (existAccount) {
|
||||
return await this.getUserById(existAccount.userId);
|
||||
}
|
||||
|
||||
// user exist check
|
||||
const existUser = await this.getUserByEmail(email);
|
||||
if (!existUser) {
|
||||
const userId = generateUserId();
|
||||
let avatar: string | undefined = undefined;
|
||||
if (avatarUrl) {
|
||||
avatar = await this.uploadAvatarByUrl(userId, avatarUrl);
|
||||
}
|
||||
return await this.createUser(
|
||||
{ id: userId, email, name, avatar },
|
||||
{ provider, providerId, type }
|
||||
);
|
||||
}
|
||||
|
||||
await this.prismaService.txClient().account.create({
|
||||
data: { id: generateAccountId(), provider, providerId, type, userId: existUser.id },
|
||||
});
|
||||
return existUser;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -80,3 +80,9 @@ API_DOC_DISENABLED=false
|
||||
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_API_ENDPOINT=
|
||||
|
||||
# Social signin providers
|
||||
BACKEND_GITHUB_CLIENT_ID=github_client_id
|
||||
BACKEND_GITHUB_CLIENT_SECRET=github_client_secret
|
||||
# separated by ','
|
||||
SOCIAL_AUTH_PROVIDERS=github,google
|
||||
|
45
apps/nextjs-app/src/features/auth/components/SocialAuth.tsx
Normal file
45
apps/nextjs-app/src/features/auth/components/SocialAuth.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { GithubLogo } from '@teable/icons';
|
||||
import { Button, Separator } from '@teable/ui-lib/shadcn';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useMemo } from 'react';
|
||||
import { useEnv } from '@/features/app/hooks/useEnv';
|
||||
|
||||
const providersAll = [
|
||||
{
|
||||
id: 'github',
|
||||
text: 'Github',
|
||||
Icon: GithubLogo,
|
||||
authUrl: '/api/auth/github',
|
||||
},
|
||||
];
|
||||
|
||||
export const SocialAuth = () => {
|
||||
const { socialAuthProviders } = useEnv();
|
||||
const router = useRouter();
|
||||
const redirect = router.query.redirect as string;
|
||||
|
||||
const providers = useMemo(
|
||||
() => providersAll.filter((provider) => socialAuthProviders?.includes(provider.id)),
|
||||
[socialAuthProviders]
|
||||
);
|
||||
|
||||
const onClick = (authUrl: string) => {
|
||||
window.location.href = redirect
|
||||
? `${authUrl}?redirect_uri=${encodeURIComponent(redirect)}`
|
||||
: authUrl;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-5" />
|
||||
<div>
|
||||
{providers.map(({ id, text, Icon, authUrl }) => (
|
||||
<Button key={id} className="w-full" variant="outline" onClick={() => onClick(authUrl)}>
|
||||
<Icon className="size-4" />
|
||||
{text}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
@ -9,6 +9,7 @@ import { useState, type FC, useCallback } from 'react';
|
||||
import { authConfig } from '@/features/i18n/auth.config';
|
||||
import type { ISignForm } from '../components/SignForm';
|
||||
import { SignForm } from '../components/SignForm';
|
||||
import { SocialAuth } from '../components/SocialAuth';
|
||||
|
||||
const queryClient = createQueryClient();
|
||||
|
||||
@ -36,11 +37,10 @@ export const LoginPage: FC = () => {
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<SignForm
|
||||
className="mx-auto h-full w-80 items-center py-[5em] lg:py-24"
|
||||
type={signType}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
<div className="relative top-1/2 mx-auto w-80 -translate-y-1/2 py-[5em] lg:py-24">
|
||||
<SignForm type={signType} onSuccess={onSuccess} />
|
||||
<SocialAuth />
|
||||
</div>
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
@ -3,6 +3,8 @@ import React from 'react';
|
||||
export interface IServerEnv {
|
||||
templateSiteLink?: string;
|
||||
microsoftClarityId?: string;
|
||||
sentryDsn?: string;
|
||||
socialAuthProviders?: string[];
|
||||
}
|
||||
|
||||
export const EnvContext = React.createContext<IServerEnv>({});
|
||||
|
@ -114,6 +114,7 @@ MyApp.getInitialProps = async (appContext: AppContext) => {
|
||||
templateSiteLink: process.env.TEMPLATE_SITE_LINK,
|
||||
microsoftClarityId: process.env.MICROSOFT_CLARITY_ID,
|
||||
sentryDsn: process.env.SENTRY_DSN,
|
||||
socialAuthProviders: process.env.SOCIAL_AUTH_PROVIDERS?.split(','),
|
||||
},
|
||||
};
|
||||
if (!isLoginPage && !needLoginPage) {
|
||||
|
@ -17,6 +17,7 @@ export enum IdPrefix {
|
||||
WorkflowDecision = 'wde',
|
||||
|
||||
User = 'usr',
|
||||
Account = 'aco',
|
||||
|
||||
Invitation = 'inv',
|
||||
|
||||
@ -112,3 +113,7 @@ export function generateNotificationId() {
|
||||
export function generateAccessTokenId() {
|
||||
return IdPrefix.AccessToken + getRandomString(16);
|
||||
}
|
||||
|
||||
export function generateAccountId() {
|
||||
return IdPrefix.Account + getRandomString(16);
|
||||
}
|
||||
|
@ -24,7 +24,7 @@ const FileCsv = (props: SVGProps<SVGSVGElement>) => (
|
||||
/>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="m8.943 13.687-.077.296c-.093.47-.338.895-.696 1.212-.358.25-.789.374-1.225.354-.778 0-1.352-.235-1.709-.698s-.516-1.138-.516-2.032.177-1.548.53-2.014c.351-.465.904-.738 1.658-.738.428-.023.854.084 1.22.307.321.236.542.583.619.974l.08.293h2.116l-.072-.455a3.47 3.47 0 0 0-1.243-2.262 4.13 4.13 0 0 0-2.696-.857c-1.46 0-2.593.497-3.363 1.471-.659.836-1.013 1.942-1.013 3.28 0 1.34.328 2.456.973 3.266.762.97 1.916 1.463 3.44 1.463a4 4 0 0 0 2.556-.857 4.1 4.1 0 0 0 1.452-2.532l.09-.471z"
|
||||
d="m8.943 13.687-.077.296c-.093.47-.337.895-.696 1.212-.358.25-.789.374-1.225.354-.778 0-1.352-.235-1.709-.698s-.516-1.138-.516-2.032.177-1.548.53-2.014c.351-.465.904-.738 1.658-.738.429-.023.854.084 1.22.307.321.236.542.583.62.974l.078.293h2.117l-.071-.455a3.47 3.47 0 0 0-1.244-2.262 4.13 4.13 0 0 0-2.696-.857c-1.46 0-2.593.497-3.363 1.471-.659.836-1.013 1.942-1.013 3.28 0 1.34.328 2.456.974 3.266.762.97 1.915 1.463 3.44 1.463a4 4 0 0 0 2.555-.857 4.1 4.1 0 0 0 1.452-2.532l.09-.471z"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
|
25
packages/icons/src/components/GithubLogo.tsx
Normal file
25
packages/icons/src/components/GithubLogo.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import * as React from 'react';
|
||||
import type { SVGProps } from 'react';
|
||||
const GithubLogo = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<g clipPath="url(#prefix__a)">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.39 6.42a12.1 12.1 0 0 0-4.367-4.477Q15.266.293 12 .293t-6.023 1.65A12.1 12.1 0 0 0 1.609 6.42Q0 9.248 0 12.594q0 4.02 2.289 7.232 2.289 3.21 5.914 4.444.422.08.625-.112a.63.63 0 0 0 .203-.48l-.008-.865q-.008-.712-.008-1.425l-.36.063q-.342.065-.866.056a6.4 6.4 0 0 1-1.086-.112 2.4 2.4 0 0 1-1.047-.48 2.03 2.03 0 0 1-.688-.985l-.156-.368a4 4 0 0 0-.492-.817q-.336-.45-.68-.609l-.109-.08a1.2 1.2 0 0 1-.203-.192.9.9 0 0 1-.14-.224q-.048-.113.078-.185.124-.072.453-.071l.312.047q.312.064.773.385.462.32.758.832.36.657.867 1.002.508.345 1.024.344.515 0 .89-.08.375-.081.703-.24.141-1.074.766-1.65a10.5 10.5 0 0 1-1.602-.289 6.3 6.3 0 0 1-1.468-.625 4.2 4.2 0 0 1-1.258-1.073q-.5-.64-.82-1.681-.32-1.042-.32-2.403 0-1.938 1.234-3.3-.58-1.456.11-3.267.453-.144 1.343.217.891.36 1.305.616.414.255.664.433a10.8 10.8 0 0 1 3-.417q1.546 0 3 .417l.594-.385a8.3 8.3 0 0 1 1.437-.704q.828-.321 1.266-.176.703 1.809.125 3.267 1.234 1.36 1.234 3.3 0 1.36-.32 2.41t-.828 1.681q-.507.633-1.266 1.065a6.3 6.3 0 0 1-1.469.625q-.71.193-1.6.289.81.72.811 2.274v3.38q0 .286.196.48.195.191.617.111 3.625-1.232 5.914-4.444Q24 16.615 24 12.594q0-3.347-1.61-6.174"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="prefix__a">
|
||||
<path fill="#fff" d="M0 0h24v24H0z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
export default GithubLogo;
|
@ -14,7 +14,7 @@ const Phone = (props: SVGProps<SVGSVGElement>) => (
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M22 16.92v3a2 2 0 0 1-2.18 2 19.8 19.8 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.8 19.8 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.338 1.85.573 2.81.7A2 2 0 0 1 22 16.92"
|
||||
d="M22 16.92v3a2 2 0 0 1-2.18 2 19.8 19.8 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.8 19.8 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.362 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.338 1.85.573 2.81.7A2 2 0 0 1 22 16.92"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
@ -57,6 +57,7 @@ export { default as FreezeColumn } from './components/FreezeColumn';
|
||||
export { default as Frown } from './components/Frown';
|
||||
export { default as Gauge } from './components/Gauge';
|
||||
export { default as Github } from './components/Github';
|
||||
export { default as GithubLogo } from './components/GithubLogo';
|
||||
export { default as Hash } from './components/Hash';
|
||||
export { default as Heart } from './components/Heart';
|
||||
export { default as HelpCircle } from './components/HelpCircle';
|
||||
|
@ -270,6 +270,9 @@ importers:
|
||||
passport:
|
||||
specifier: 0.7.0
|
||||
version: 0.7.0
|
||||
passport-github2:
|
||||
specifier: 0.1.12
|
||||
version: 0.1.12
|
||||
passport-jwt:
|
||||
specifier: 4.0.1
|
||||
version: 4.0.1
|
||||
@ -379,6 +382,9 @@ importers:
|
||||
'@types/passport':
|
||||
specifier: 1.0.16
|
||||
version: 1.0.16
|
||||
'@types/passport-github2':
|
||||
specifier: 1.2.9
|
||||
version: 1.2.9
|
||||
'@types/passport-jwt':
|
||||
specifier: 4.0.1
|
||||
version: 4.0.1
|
||||
@ -10305,6 +10311,12 @@ packages:
|
||||
resolution: {integrity: sha512-k7kRA033QNtC+gLc4VPlfnue58CM1iQLgn1IMAU8VPHGOj7oIHPp9UlhedEnD/Gl8evoCjwkZjlBORtZ3JByUA==}
|
||||
dev: true
|
||||
|
||||
/@types/oauth@0.9.4:
|
||||
resolution: {integrity: sha512-qk9orhti499fq5XxKCCEbd0OzdPZuancneyse3KtR+vgMiHRbh+mn8M4G6t64ob/Fg+GZGpa565MF/2dKWY32A==}
|
||||
dependencies:
|
||||
'@types/node': 20.9.0
|
||||
dev: true
|
||||
|
||||
/@types/papaparse@5.3.14:
|
||||
resolution: {integrity: sha512-LxJ4iEFcpqc6METwp9f6BV6VVc43m6MfH0VqFosHvrUgfXiFe6ww7R3itkOQ+TCK6Y+Iv/+RnnvtRZnkc5Kc9g==}
|
||||
dependencies:
|
||||
@ -10315,6 +10327,14 @@ packages:
|
||||
resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==}
|
||||
dev: true
|
||||
|
||||
/@types/passport-github2@1.2.9:
|
||||
resolution: {integrity: sha512-/nMfiPK2E6GKttwBzwj0Wjaot8eHrM57hnWxu52o6becr5/kXlH/4yE2v2rh234WGvSgEEzIII02Nc5oC5xEHA==}
|
||||
dependencies:
|
||||
'@types/express': 4.17.21
|
||||
'@types/passport': 1.0.16
|
||||
'@types/passport-oauth2': 1.4.15
|
||||
dev: true
|
||||
|
||||
/@types/passport-jwt@4.0.1:
|
||||
resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==}
|
||||
dependencies:
|
||||
@ -10330,6 +10350,14 @@ packages:
|
||||
'@types/passport-strategy': 0.2.38
|
||||
dev: true
|
||||
|
||||
/@types/passport-oauth2@1.4.15:
|
||||
resolution: {integrity: sha512-9cUTP/HStNSZmhxXGuRrBJfEWzIEJRub2eyJu3CvkA+8HAMc9W3aKdFhVq+Qz1hi42qn+GvSAnz3zwacDSYWpw==}
|
||||
dependencies:
|
||||
'@types/express': 4.17.21
|
||||
'@types/oauth': 0.9.4
|
||||
'@types/passport': 1.0.16
|
||||
dev: true
|
||||
|
||||
/@types/passport-strategy@0.2.38:
|
||||
resolution: {integrity: sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==}
|
||||
dependencies:
|
||||
@ -11873,6 +11901,11 @@ packages:
|
||||
/base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
/base64url@3.0.1:
|
||||
resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
dev: false
|
||||
|
||||
/base@0.11.2:
|
||||
resolution: {integrity: sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@ -20552,6 +20585,10 @@ packages:
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: true
|
||||
|
||||
/oauth@0.10.0:
|
||||
resolution: {integrity: sha512-1orQ9MT1vHFGQxhuy7E/0gECD3fd2fCC+PIX+/jgmU/gI3EpRocXtmtvxCO5x3WZ443FLTLFWNDjl5MPJf9u+Q==}
|
||||
dev: false
|
||||
|
||||
/object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@ -21046,6 +21083,13 @@ packages:
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: true
|
||||
|
||||
/passport-github2@0.1.12:
|
||||
resolution: {integrity: sha512-3nPUCc7ttF/3HSP/k9sAXjz3SkGv5Nki84I05kSQPo01Jqq1NzJACgMblCK0fGcv9pKCG/KXU3AJRDGLqHLoIw==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
dependencies:
|
||||
passport-oauth2: 1.8.0
|
||||
dev: false
|
||||
|
||||
/passport-jwt@4.0.1:
|
||||
resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==}
|
||||
dependencies:
|
||||
@ -21060,6 +21104,17 @@ packages:
|
||||
passport-strategy: 1.0.0
|
||||
dev: false
|
||||
|
||||
/passport-oauth2@1.8.0:
|
||||
resolution: {integrity: sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
dependencies:
|
||||
base64url: 3.0.1
|
||||
oauth: 0.10.0
|
||||
passport-strategy: 1.0.0
|
||||
uid2: 0.0.4
|
||||
utils-merge: 1.0.1
|
||||
dev: false
|
||||
|
||||
/passport-strategy@1.0.0:
|
||||
resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
@ -25962,6 +26017,10 @@ packages:
|
||||
random-bytes: 1.0.0
|
||||
dev: false
|
||||
|
||||
/uid2@0.0.4:
|
||||
resolution: {integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==}
|
||||
dev: false
|
||||
|
||||
/uid@2.0.2:
|
||||
resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==}
|
||||
engines: {node: '>=8'}
|
||||
|
Loading…
Reference in New Issue
Block a user