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:
Boris 2024-03-13 17:22:31 +08:00 committed by GitHub
parent a8e3442dc6
commit 61044b1c92
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 504 additions and 77 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class GithubGuard extends AuthGuard('github') {}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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