mirror of https://github.com/sylv/micro.git
feat: mfa support (#29)
This commit is contained in:
parent
ec071a47f6
commit
f43a8e0cb5
|
@ -28,6 +28,7 @@ An invite-only file sharing service with support for ShareX. You can see a previ
|
|||
- [x] EXIF metadata removal
|
||||
- [x] Conversions (GIF>WebM, WebP>PNG, etc.)
|
||||
- [x] Purging of old and/or large files (`config.purge`).
|
||||
- [x] 2FA support
|
||||
|
||||
## screenshots
|
||||
|
||||
|
@ -44,6 +45,7 @@ An invite-only file sharing service with support for ShareX. You can see a previ
|
|||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://i.imgur.com/1KUrtVf.png" title="Paste Page" alt="Paste Page"></td>
|
||||
<td><img src="https://i.imgur.com/GYaEcKy.png" title="2FA setup" alt="2FA setup"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
"node": ">=16"
|
||||
},
|
||||
"scripts": {
|
||||
"watch": "tsup src/main.ts src/migrations/* --watch --onSuccess \"node dist/main.js\" --target node16",
|
||||
"watch": "rm -rf ./dist && tsup src/main.ts src/migrations/* --watch --onSuccess \"node dist/main.js\" --target node16",
|
||||
"build": "rm -rf ./dist && ncc build src/main.ts -o dist --minify --transpile-only --v8-cache --no-source-map-register",
|
||||
"lint": "eslint src --fix --cache",
|
||||
"test": "jest"
|
||||
|
@ -55,6 +55,7 @@
|
|||
"nanoid": "^3.3.4",
|
||||
"nodemailer": "^6.7.6",
|
||||
"normalize-url": "^6",
|
||||
"otplib": "^12.0.1",
|
||||
"passport": "^0.6.0",
|
||||
"passport-jwt": "^4.0.0",
|
||||
"passport-local": "^1.0.0",
|
||||
|
|
|
@ -87,6 +87,34 @@
|
|||
"primary": false,
|
||||
"nullable": false,
|
||||
"mappedType": "array"
|
||||
},
|
||||
"otp_secret": {
|
||||
"name": "otp_secret",
|
||||
"type": "varchar(255)",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": true,
|
||||
"mappedType": "string"
|
||||
},
|
||||
"otp_enabled": {
|
||||
"name": "otp_enabled",
|
||||
"type": "boolean",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"default": "false",
|
||||
"mappedType": "boolean"
|
||||
},
|
||||
"otp_recovery_codes": {
|
||||
"name": "otp_recovery_codes",
|
||||
"type": "text[]",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": true,
|
||||
"mappedType": "array"
|
||||
}
|
||||
},
|
||||
"name": "users",
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
export class Migration20221006070931 extends Migration {
|
||||
|
||||
async up(): Promise<void> {
|
||||
this.addSql('alter table "users" add column "otp_secret" varchar(255) null, add column "otp_enabled" boolean not null default false, add column "otp_recovery_codes" text[] null;');
|
||||
}
|
||||
|
||||
async down(): Promise<void> {
|
||||
this.addSql('alter table "users" drop column "otp_secret";');
|
||||
this.addSql('alter table "users" drop column "otp_enabled";');
|
||||
this.addSql('alter table "users" drop column "otp_recovery_codes";');
|
||||
}
|
||||
|
||||
}
|
|
@ -1,44 +0,0 @@
|
|||
import { Controller, Post, Req, Res, UseGuards } from '@nestjs/common';
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { config } from '../../config';
|
||||
import type { JWTPayloadUser } from './strategies/jwt.strategy';
|
||||
import { AuthService, TokenType } from './auth.service';
|
||||
import ms from 'ms';
|
||||
import { PasswordAuthGuard } from './guards/password.guard';
|
||||
|
||||
@Controller()
|
||||
export class AuthController {
|
||||
private static readonly ONE_YEAR = ms('1y');
|
||||
private static readonly COOKIE_OPTIONS = {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
domain: config.rootHost.normalised.split(':').shift(),
|
||||
secure: config.rootHost.url.startsWith('https'),
|
||||
};
|
||||
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('auth/login')
|
||||
@UseGuards(PasswordAuthGuard)
|
||||
async login(@Req() request: FastifyRequest, @Res() reply: FastifyReply) {
|
||||
const payload: JWTPayloadUser = { name: request.user.username, id: request.user.id, secret: request.user.secret };
|
||||
const expiresAt = Date.now() + AuthController.ONE_YEAR;
|
||||
const token = await this.authService.signToken<JWTPayloadUser>(TokenType.USER, payload, '1y');
|
||||
return reply
|
||||
.setCookie('token', token, {
|
||||
...AuthController.COOKIE_OPTIONS,
|
||||
expires: new Date(expiresAt),
|
||||
})
|
||||
.send({ ok: true });
|
||||
}
|
||||
|
||||
@Post('auth/logout')
|
||||
async logout(@Res() reply: FastifyReply) {
|
||||
return reply
|
||||
.setCookie('token', '', {
|
||||
...AuthController.COOKIE_OPTIONS,
|
||||
expires: new Date(),
|
||||
})
|
||||
.send({ ok: true });
|
||||
}
|
||||
}
|
|
@ -2,15 +2,14 @@ import { MikroOrmModule } from '@mikro-orm/nestjs';
|
|||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { config } from '../../config';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JWTStrategy } from './strategies/jwt.strategy';
|
||||
import { PasswordStrategy } from './strategies/password.strategy';
|
||||
import { User } from '../user/user.entity';
|
||||
import { AuthResolver } from './auth.resolver';
|
||||
|
||||
@Module({
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, PasswordStrategy, JWTStrategy],
|
||||
controllers: [],
|
||||
providers: [AuthResolver, AuthService, JWTStrategy],
|
||||
exports: [AuthService],
|
||||
imports: [
|
||||
MikroOrmModule.forFeature([User]),
|
||||
|
|
|
@ -0,0 +1,85 @@
|
|||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { EntityRepository } from '@mikro-orm/postgresql';
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { Args, Context, Mutation, Resolver } from '@nestjs/graphql';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import ms from 'ms';
|
||||
import { config } from '../../config';
|
||||
import { User } from '../user/user.entity';
|
||||
import { UserId } from './auth.decorators';
|
||||
import { AuthService, TokenType } from './auth.service';
|
||||
import { OTPEnabledDto } from './dto/otp-enabled.dto';
|
||||
import { JWTAuthGuard } from './guards/jwt.guard';
|
||||
import type { JWTPayloadUser } from './strategies/jwt.strategy';
|
||||
|
||||
@Resolver(() => User)
|
||||
export class AuthResolver {
|
||||
private static readonly ONE_YEAR = ms('1y');
|
||||
private static readonly COOKIE_OPTIONS = {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
domain: config.rootHost.normalised.split(':').shift(),
|
||||
secure: config.rootHost.url.startsWith('https'),
|
||||
};
|
||||
|
||||
constructor(
|
||||
@InjectRepository(User) private readonly userRepo: EntityRepository<User>,
|
||||
private readonly authService: AuthService
|
||||
) {}
|
||||
|
||||
@Mutation(() => User)
|
||||
async login(
|
||||
@Context() ctx: any,
|
||||
@Args('username') username: string,
|
||||
@Args('password') password: string,
|
||||
@Args('otpCode', { nullable: true }) otpCode?: string
|
||||
) {
|
||||
const reply = ctx.reply as FastifyReply;
|
||||
const user = await this.authService.authenticateUser(username, password, otpCode);
|
||||
const payload: JWTPayloadUser = { name: user.username, id: user.id, secret: user.secret };
|
||||
const expiresAt = Date.now() + AuthResolver.ONE_YEAR;
|
||||
const token = await this.authService.signToken<JWTPayloadUser>(TokenType.USER, payload, '1y');
|
||||
void reply.setCookie('token', token, {
|
||||
...AuthResolver.COOKIE_OPTIONS,
|
||||
expires: new Date(expiresAt),
|
||||
});
|
||||
|
||||
// fixes querying things like user.token, which requires req.user to match the query user
|
||||
ctx.req.user = user;
|
||||
return user;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async logout(@Context() ctx: any) {
|
||||
const reply = ctx.reply as FastifyReply;
|
||||
void reply.setCookie('token', '', {
|
||||
...AuthResolver.COOKIE_OPTIONS,
|
||||
expires: new Date(),
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => OTPEnabledDto)
|
||||
@UseGuards(JWTAuthGuard)
|
||||
async generateOTP(@UserId() userId: string) {
|
||||
const user = await this.userRepo.findOneOrFail(userId);
|
||||
return this.authService.generateOTP(user);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@UseGuards(JWTAuthGuard)
|
||||
async confirmOTP(@UserId() userId: string, @Args('otpCode') otpCode: string) {
|
||||
const user = await this.userRepo.findOneOrFail(userId);
|
||||
await this.authService.confirmOTP(user, otpCode);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@UseGuards(JWTAuthGuard)
|
||||
async disableOTP(@UserId() userId: string, @Args('otpCode') otpCode: string) {
|
||||
const user = await this.userRepo.findOneOrFail(userId);
|
||||
await this.authService.disableOTP(user, otpCode);
|
||||
return true;
|
||||
}
|
||||
}
|
|
@ -1,5 +1,12 @@
|
|||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { EntityRepository } from '@mikro-orm/postgresql';
|
||||
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import crypto from 'crypto';
|
||||
import { authenticator } from 'otplib';
|
||||
import { User } from '../user/user.entity';
|
||||
import type { OTPEnabledDto } from './dto/otp-enabled.dto';
|
||||
|
||||
export enum TokenType {
|
||||
USER = 'USER',
|
||||
|
@ -13,9 +20,14 @@ export interface TokenPayload {
|
|||
iat: number;
|
||||
}
|
||||
|
||||
const NUMBER_REGEX = /^\d{6}$/u;
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(private readonly jwtService: JwtService) {}
|
||||
constructor(
|
||||
@InjectRepository(User) private readonly userRepo: EntityRepository<User>,
|
||||
private readonly jwtService: JwtService
|
||||
) {}
|
||||
|
||||
signToken<PayloadType extends Record<string, any>>(type: TokenType, payload: PayloadType, expiresIn = '1y') {
|
||||
return this.jwtService.signAsync(payload, {
|
||||
|
@ -36,4 +48,133 @@ export class AuthService {
|
|||
throw new BadRequestException('Token validation failed.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticaet a user by username and password.
|
||||
*/
|
||||
async authenticateUser(username: string, password: string, otpCode?: string) {
|
||||
const user = await this.userRepo.findOne({
|
||||
$or: [{ username }, { email: { $ilike: username } }],
|
||||
});
|
||||
|
||||
if (!user) throw new UnauthorizedException();
|
||||
const passwordMatches = await bcrypt.compare(password, user.password);
|
||||
if (!passwordMatches) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
if (user.otpEnabled) {
|
||||
// account has otp enabled, check if the code is valid
|
||||
await this.validateOTPCode(otpCode, user);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds OTP codes to a user, without enabling OTP.
|
||||
* This is the first step in enabling OTP, next will be to get the user to verify the code using enableOTP().
|
||||
*/
|
||||
async generateOTP(user: User): Promise<OTPEnabledDto> {
|
||||
if (user.otpEnabled) {
|
||||
throw new UnauthorizedException('User already has OTP enabled.');
|
||||
}
|
||||
|
||||
const recoveryCodes = [];
|
||||
user.otpSecret = authenticator.generateSecret();
|
||||
user.otpRecoveryCodes = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const code = crypto
|
||||
.randomBytes(8)
|
||||
.toString('hex')
|
||||
.match(/.{1,4}/gu)!
|
||||
.join('-');
|
||||
const hashedCode = crypto.createHash('sha256').update(code).digest('hex');
|
||||
user.otpRecoveryCodes.push(hashedCode);
|
||||
recoveryCodes.push(code);
|
||||
}
|
||||
|
||||
await this.userRepo.persistAndFlush(user);
|
||||
return {
|
||||
recoveryCodes,
|
||||
secret: user.otpSecret,
|
||||
qrauthUrl: authenticator.keyuri(user.username, 'Micro', user.otpSecret),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable OTP after the user has verified the code.
|
||||
* Start by calling generateOTP() to get the code.
|
||||
*/
|
||||
async confirmOTP(user: User, otpCode: string) {
|
||||
if (user.otpEnabled) {
|
||||
throw new UnauthorizedException('User already has OTP enabled.');
|
||||
}
|
||||
|
||||
if (!user.otpSecret || !user.otpRecoveryCodes || !user.otpRecoveryCodes[0]) {
|
||||
throw new Error('User does not have 2FA codes.');
|
||||
}
|
||||
|
||||
user.otpEnabled = true;
|
||||
await this.validateOTPCode(otpCode, user);
|
||||
await this.userRepo.persistAndFlush(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable OTP for a user.
|
||||
* @param otpCode Either a recovery code or an OTP code.
|
||||
*/
|
||||
async disableOTP(user: User, otpCode: string) {
|
||||
await this.validateOTPCode(otpCode, user);
|
||||
user.otpSecret = undefined;
|
||||
user.otpRecoveryCodes = undefined;
|
||||
user.otpEnabled = false;
|
||||
await this.userRepo.persistAndFlush(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an OTP code for a user.
|
||||
* Supports recovery codes.g
|
||||
* @throws if the user does not have OTP enabled, check beforehand.
|
||||
*/
|
||||
private async validateOTPCode(otpCode: string | undefined, user: User) {
|
||||
if (!user.otpEnabled || !user.otpSecret) {
|
||||
throw new Error('User does not have OTP enabled.');
|
||||
}
|
||||
|
||||
if (!otpCode) {
|
||||
throw new UnauthorizedException('OTP code is required.');
|
||||
}
|
||||
|
||||
if (this.isOTPCode(otpCode)) {
|
||||
// user gave us an otp code
|
||||
const isValid = authenticator.check(otpCode, user.otpSecret);
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedException('Invalid OTP code.');
|
||||
}
|
||||
} else {
|
||||
// user likely gave us a recovery code, or garbage
|
||||
const hashedRecoveryCode = crypto.createHash('sha256').update(otpCode.toLowerCase()).digest('hex');
|
||||
if (!user.otpRecoveryCodes) {
|
||||
throw new Error('User has no recovery codes.');
|
||||
}
|
||||
|
||||
const codeIndex = user.otpRecoveryCodes.indexOf(hashedRecoveryCode);
|
||||
if (codeIndex === -1) {
|
||||
throw new UnauthorizedException('Invalid or already used recovery code.');
|
||||
}
|
||||
|
||||
// remove recovery code
|
||||
user.otpRecoveryCodes.splice(codeIndex, 1);
|
||||
await this.userRepo.persistAndFlush(user);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the input is numeric OTP code or not.
|
||||
* Usually if this is false, the code will be a recovery code. Or garbage.
|
||||
*/
|
||||
private isOTPCode(input: string) {
|
||||
return NUMBER_REGEX.test(input);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class OTPEnabledDto {
|
||||
@Field(() => [String])
|
||||
recoveryCodes: string[];
|
||||
|
||||
@Field()
|
||||
secret: string;
|
||||
|
||||
@Field()
|
||||
qrauthUrl: string;
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class PasswordAuthGuard extends AuthGuard('local') {}
|
|
@ -1,27 +0,0 @@
|
|||
import { EntityRepository } from '@mikro-orm/core';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { Strategy } from 'passport-local';
|
||||
import { User } from '../../user/user.entity';
|
||||
|
||||
@Injectable()
|
||||
export class PasswordStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(@InjectRepository(User) private readonly userRepo: EntityRepository<User>) {
|
||||
super();
|
||||
}
|
||||
|
||||
async validate(username: string, password: string): Promise<FastifyRequest['user']> {
|
||||
const lowerUsername = username.toLowerCase();
|
||||
const user = await this.userRepo.findOne({
|
||||
$or: [{ username: lowerUsername }, { email: { $ilike: username } }],
|
||||
});
|
||||
|
||||
if (!user) throw new UnauthorizedException();
|
||||
const passwordMatches = await bcrypt.compare(password, user.password);
|
||||
if (!passwordMatches) throw new UnauthorizedException();
|
||||
return user;
|
||||
}
|
||||
}
|
|
@ -1,4 +1,14 @@
|
|||
import { Collection, Entity, Index, OneToMany, OneToOne, OptionalProps, PrimaryKey, Property } from '@mikro-orm/core';
|
||||
import {
|
||||
ArrayType,
|
||||
Collection,
|
||||
Entity,
|
||||
Index,
|
||||
OneToMany,
|
||||
OneToOne,
|
||||
OptionalProps,
|
||||
PrimaryKey,
|
||||
Property,
|
||||
} from '@mikro-orm/core';
|
||||
import { Field, ID, ObjectType } from '@nestjs/graphql';
|
||||
import { Exclude } from 'class-transformer';
|
||||
import { generateContentId } from '../../helpers/generate-content-id.helper';
|
||||
|
@ -49,13 +59,25 @@ export class User {
|
|||
@Exclude()
|
||||
files = new Collection<File>(this);
|
||||
|
||||
@Exclude()
|
||||
@OneToMany(() => UserVerification, (verification) => verification.user, {
|
||||
orphanRemoval: true,
|
||||
persist: true,
|
||||
hidden: true,
|
||||
})
|
||||
@Exclude()
|
||||
verifications = new Collection<UserVerification>(this);
|
||||
|
||||
@Exclude()
|
||||
@Property({ nullable: true, hidden: true })
|
||||
otpSecret?: string;
|
||||
|
||||
@Field()
|
||||
@Property({ default: false, hidden: true })
|
||||
otpEnabled: boolean;
|
||||
|
||||
@Exclude()
|
||||
@Property({ nullable: true, hidden: true, type: ArrayType })
|
||||
otpRecoveryCodes?: string[];
|
||||
|
||||
[OptionalProps]: 'permissions' | 'tags' | 'verifiedEmail';
|
||||
}
|
||||
|
|
|
@ -40,17 +40,20 @@ export class UserResolver {
|
|||
}
|
||||
|
||||
@ResolveField(() => Number)
|
||||
async aggregateFileSize(@Parent() user: User) {
|
||||
async aggregateFileSize(@UserId() userId: string, @Parent() user: User) {
|
||||
if (userId !== user.id) throw new UnauthorizedException();
|
||||
const result = await this.fileRepo.createQueryBuilder().where({ owner: user.id }).getKnex().sum('size').first();
|
||||
return Number(result.sum);
|
||||
}
|
||||
|
||||
@ResolveField(() => FilePage)
|
||||
async files(
|
||||
@UserId() userId: string,
|
||||
@Parent() user: User,
|
||||
@Args('first', { nullable: true }) limit: number = 0,
|
||||
@Args('after', { nullable: true }) cursor?: string
|
||||
): Promise<FilePage> {
|
||||
if (userId !== user.id) throw new UnauthorizedException();
|
||||
if (limit > 100) limit = 100;
|
||||
if (limit <= 0) limit = 10;
|
||||
const query: FilterQuery<File> = { owner: user.id };
|
||||
|
@ -68,10 +71,12 @@ export class UserResolver {
|
|||
|
||||
@ResolveField(() => PastePage)
|
||||
async pastes(
|
||||
@UserId() userId: string,
|
||||
@Parent() user: User,
|
||||
@Args('first', { nullable: true }) limit: number = 0,
|
||||
@Args('after', { nullable: true }) cursor?: string
|
||||
): Promise<PastePage> {
|
||||
if (userId !== user.id) throw new UnauthorizedException();
|
||||
if (limit > 100) limit = 100;
|
||||
if (limit <= 0) limit = 10;
|
||||
const query: FilterQuery<Paste> = { owner: user.id };
|
||||
|
@ -88,7 +93,8 @@ export class UserResolver {
|
|||
}
|
||||
|
||||
@ResolveField(() => String)
|
||||
async token(@Parent() user: User) {
|
||||
async token(@UserId() userId: string, @Parent() user: User) {
|
||||
if (userId !== user.id) throw new UnauthorizedException();
|
||||
return this.authService.signToken<JWTPayloadUser>(TokenType.USER, {
|
||||
name: user.username,
|
||||
secret: user.secret,
|
||||
|
|
|
@ -100,15 +100,26 @@ type Link {
|
|||
}
|
||||
|
||||
type Mutation {
|
||||
confirmOTP(otpCode: String!): Boolean!
|
||||
createInvite: Invite!
|
||||
createLink(destination: String!, host: String): Link!
|
||||
createPaste(partial: CreatePasteDto!): Paste!
|
||||
createUser(data: CreateUserDto!): User!
|
||||
deleteFile(fileId: ID!, key: String): Boolean!
|
||||
disableOTP(otpCode: String!): Boolean!
|
||||
generateOTP: OTPEnabledDto!
|
||||
login(otpCode: String, password: String!, username: String!): User!
|
||||
logout: Boolean!
|
||||
refreshToken: User!
|
||||
resendVerificationEmail(data: ResendVerificationEmailDto): Boolean!
|
||||
}
|
||||
|
||||
type OTPEnabledDto {
|
||||
qrauthUrl: String!
|
||||
recoveryCodes: [String!]!
|
||||
secret: String!
|
||||
}
|
||||
|
||||
type PageInfo {
|
||||
endCursor: String
|
||||
hasNextPage: Boolean!
|
||||
|
@ -176,6 +187,7 @@ type User {
|
|||
email: String
|
||||
files(after: String, first: Float): FilePage!
|
||||
id: ID!
|
||||
otpEnabled: Boolean!
|
||||
pastes(after: String, first: Float): PastePage!
|
||||
permissions: Float!
|
||||
tags: [String!]!
|
||||
|
|
|
@ -6,4 +6,7 @@ generates:
|
|||
plugins:
|
||||
- 'typescript'
|
||||
- 'typescript-operations'
|
||||
- 'typescript-react-apollo'
|
||||
- 'typescript-react-apollo'
|
||||
hooks:
|
||||
afterAllFileWrite:
|
||||
- prettier --write
|
|
@ -34,6 +34,7 @@
|
|||
"next": "12.2.0",
|
||||
"postcss": "^8.4.13",
|
||||
"prism-react-renderer": "^1.3.5",
|
||||
"qrcode.react": "^3.1.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "^18.1.0",
|
||||
"react-feather": "^2.0.9",
|
||||
|
@ -53,6 +54,7 @@
|
|||
"@types/lodash": "^4.14.182",
|
||||
"@types/node": "16",
|
||||
"@types/react": "^18.0.8",
|
||||
"prettier": "^2.7.1",
|
||||
"typescript": "^4.7.4"
|
||||
}
|
||||
}
|
|
@ -22,6 +22,7 @@ export interface InputContainerProps<T> {
|
|||
maxHeight?: boolean;
|
||||
className?: string;
|
||||
style?: InputStyle;
|
||||
isError?: boolean;
|
||||
childProps: T;
|
||||
children: (data: { childClasses: string } & T) => ReactNode;
|
||||
}
|
||||
|
@ -29,7 +30,8 @@ export interface InputContainerProps<T> {
|
|||
export function InputContainer<T extends InputChildPropsBase>({
|
||||
children,
|
||||
className,
|
||||
style = InputStyle.Default,
|
||||
isError,
|
||||
style = isError ? InputStyle.Error : InputStyle.Default,
|
||||
maxHeight = true,
|
||||
childProps,
|
||||
...rest
|
||||
|
|
|
@ -3,11 +3,13 @@ import React from 'react';
|
|||
import type { InputChildProps } from './container';
|
||||
import { InputContainer } from './container';
|
||||
|
||||
export interface InputProps extends InputChildProps<InputHTMLAttributes<HTMLInputElement>> {}
|
||||
export interface InputProps extends InputChildProps<InputHTMLAttributes<HTMLInputElement>> {
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, ...delegated }, ref) => {
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, isError, ...delegated }, ref) => {
|
||||
return (
|
||||
<InputContainer className={className} childProps={delegated}>
|
||||
<InputContainer className={className} childProps={delegated} isError={isError}>
|
||||
{({ childClasses, ...rest }) => <input {...rest} className={childClasses} ref={ref} />}
|
||||
</InputContainer>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
import { Spinner } from '@ryanke/pandora';
|
||||
import type { FC } from 'react';
|
||||
import { Input } from './input';
|
||||
|
||||
export interface OtpInputProps {
|
||||
loading: boolean;
|
||||
invalid?: boolean;
|
||||
onCode: (code: string) => void;
|
||||
}
|
||||
|
||||
const TOTP_CODE_LENGTH = 6;
|
||||
const RECOVERY_CODE_LENGTH = 19;
|
||||
const NUMBER_REGEX = /^\d+$/u;
|
||||
|
||||
export const OtpInput: FC<OtpInputProps> = ({ loading, invalid, onCode }) => {
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
isError={invalid}
|
||||
placeholder="123456"
|
||||
onChange={(event) => {
|
||||
if (loading || !event.target.value) return;
|
||||
if (
|
||||
(event.target.value.length === TOTP_CODE_LENGTH && NUMBER_REGEX.test(event.target.value)) ||
|
||||
event.target.value.length === RECOVERY_CODE_LENGTH
|
||||
) {
|
||||
onCode(event.target.value);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (loading || !event.currentTarget.value) return;
|
||||
if (event.key === 'Enter') {
|
||||
onCode(event.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{loading && (
|
||||
<div className="absolute right-3 top-0 bottom-0 flex items-center justify-center">
|
||||
<Spinner size="small" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,20 @@
|
|||
import type { FC } from 'react';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
export interface PingProps {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export const Ping: FC<PingProps> = ({ active }) => {
|
||||
return (
|
||||
<div className="h-full flex relative items-center">
|
||||
{active && (
|
||||
<Fragment>
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-purple-300 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-purple-400" />
|
||||
</Fragment>
|
||||
)}
|
||||
{!active && <span className="relative inline-flex rounded-full h-3 w-3 bg-gray-600" />}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,7 +1,10 @@
|
|||
import classNames from 'classnames';
|
||||
import type { FC, ReactNode } from 'react';
|
||||
|
||||
export const Section: FC<{ className?: string; children: ReactNode }> = ({ className, children }) => {
|
||||
const classes = classNames('absolute left-0 right-0 py-8 bg-black shadow-lg', className);
|
||||
return <section className={classes}>{children}</section>;
|
||||
return (
|
||||
<div className="relative py-8">
|
||||
<section className={className}>{children}</section>
|
||||
<div className="top-0 bottom-0 absolute w-[500vw] -left-full h-full bg-black shadow-lg -z-10" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
import classNames from 'classnames';
|
||||
import type { FC } from 'react';
|
||||
import { Fragment } from 'react';
|
||||
import { Ping } from './ping';
|
||||
|
||||
export interface StepsProps {
|
||||
steps: string[];
|
||||
stepIndex: number;
|
||||
onClick?: (index: number) => void;
|
||||
}
|
||||
|
||||
export const Steps: FC<StepsProps> = ({ steps, stepIndex }) => {
|
||||
return (
|
||||
<div className="flex justify-center items-center text-sm">
|
||||
{steps.map((step, index) => {
|
||||
const isActive = index === stepIndex;
|
||||
return (
|
||||
<Fragment key={step}>
|
||||
{index !== 0 && <div className="h-px w-8 mx-4 bg-gray-800" />}
|
||||
<div className="flex items-center gap-2">
|
||||
<Ping active={isActive} />
|
||||
<div className={classNames(!isActive && 'text-gray-400')}>{step}</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -33,7 +33,7 @@ export const ConfigGenerator = () => {
|
|||
|
||||
return (
|
||||
<Section>
|
||||
<Container className="flex flex-col justify-between dots selection:bg-purple-600 py-8">
|
||||
<Container className="flex flex-col justify-between dots selection:bg-purple-600 py-8 px-0">
|
||||
<div className="w-full flex-grow">
|
||||
{!config.data && (
|
||||
<div className="flex items-center justify-center w-full h-full py-10">
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
import { useAsync } from '@ryanke/pandora';
|
||||
import classNames from 'classnames';
|
||||
import { Form, Formik } from 'formik';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { FC } from 'react';
|
||||
import { Fragment, useCallback, useEffect } from 'react';
|
||||
import { Fragment, useCallback, useEffect, useState } from 'react';
|
||||
import { OtpInput } from 'src/components/input/otp';
|
||||
import type { LoginMutationVariables } from 'src/generated/graphql';
|
||||
import * as Yup from 'yup';
|
||||
import { Input } from '../components/input/input';
|
||||
import { Submit } from '../components/input/submit';
|
||||
|
@ -15,6 +19,8 @@ const schema = Yup.object().shape({
|
|||
export const LoginForm: FC = () => {
|
||||
const user = useUser();
|
||||
const router = useRouter();
|
||||
const [loginInfo, setLoginInfo] = useState<LoginMutationVariables | null>(null);
|
||||
const [invalidOTP, setInvalidOTP] = useState(false);
|
||||
const redirect = useCallback(() => {
|
||||
const url = new URL(window.location.href);
|
||||
const to = url.searchParams.get('to') ?? '/dashboard';
|
||||
|
@ -28,12 +34,45 @@ export const LoginForm: FC = () => {
|
|||
}
|
||||
}, [user, router, redirect]);
|
||||
|
||||
const [login, loggingIn] = useAsync(async (values: LoginMutationVariables) => {
|
||||
try {
|
||||
setInvalidOTP(false);
|
||||
await user.login(values);
|
||||
redirect();
|
||||
} catch (error: any) {
|
||||
if (user.otpRequired && error.message.toLowerCase().includes('invalid otp')) {
|
||||
setInvalidOTP(true);
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
if (user.otpRequired && loginInfo) {
|
||||
return (
|
||||
<div>
|
||||
<OtpInput
|
||||
loading={loggingIn}
|
||||
invalid={invalidOTP}
|
||||
onCode={(otp) => {
|
||||
login({ ...loginInfo, otp });
|
||||
}}
|
||||
/>
|
||||
<span className={classNames(`text-xs text-center`, invalidOTP ? 'text-red-400' : 'text-gray-600')}>
|
||||
{invalidOTP ? 'Invalid OTP code' : 'Enter the OTP code from your authenticator app'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Formik
|
||||
initialValues={{ username: '', password: '' }}
|
||||
validationSchema={schema}
|
||||
onSubmit={async (values) => {
|
||||
setLoginInfo(values);
|
||||
await user.login(values);
|
||||
redirect();
|
||||
}}
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -10,7 +10,7 @@ export const useQueryState = <S>(key: string, initialState?: S, parser?: (input:
|
|||
const result = parser ? parser(value) : (value as any);
|
||||
setValue(result);
|
||||
}
|
||||
}, [key, parser]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const route = new URL(window.location.href);
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
query GetUser {
|
||||
user {
|
||||
...RegularUser
|
||||
otpEnabled
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -11,3 +12,29 @@ fragment RegularUser on User {
|
|||
verifiedEmail
|
||||
token
|
||||
}
|
||||
|
||||
mutation Login($username: String!, $password: String!, $otp: String) {
|
||||
login(username: $username, password: $password, otpCode: $otp) {
|
||||
...RegularUser
|
||||
}
|
||||
}
|
||||
|
||||
mutation Logout {
|
||||
logout
|
||||
}
|
||||
|
||||
mutation GenerateOTP {
|
||||
generateOTP {
|
||||
recoveryCodes
|
||||
qrauthUrl
|
||||
secret
|
||||
}
|
||||
}
|
||||
|
||||
mutation ConfirmOTP($otpCode: String!) {
|
||||
confirmOTP(otpCode: $otpCode)
|
||||
}
|
||||
|
||||
mutation DisableOTP($otpCode: String!) {
|
||||
disableOTP(otpCode: $otpCode)
|
||||
}
|
||||
|
|
|
@ -1,32 +1,37 @@
|
|||
import { useAsync } from '@ryanke/pandora';
|
||||
import Router, { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { resetClient } from '../apollo';
|
||||
import { useGetUserQuery } from '../generated/graphql';
|
||||
import { http } from '../helpers/http.helper';
|
||||
|
||||
interface LoginData {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
import type { LoginMutationVariables } from '../generated/graphql';
|
||||
import { useGetUserQuery, useLoginMutation, useLogoutMutation } from '../generated/graphql';
|
||||
|
||||
export const useUser = (redirect = false) => {
|
||||
const user = useGetUserQuery();
|
||||
const router = useRouter();
|
||||
const [loginMutation] = useLoginMutation();
|
||||
const [logoutMutation] = useLogoutMutation();
|
||||
const [otp, setOtp] = useState(false);
|
||||
|
||||
const [login] = useAsync(async (data: LoginData) => {
|
||||
await http(`auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const [login] = useAsync(async (variables: LoginMutationVariables) => {
|
||||
try {
|
||||
await loginMutation({
|
||||
variables: variables,
|
||||
});
|
||||
|
||||
await user.refetch();
|
||||
Router.push('/dashboard');
|
||||
await user.refetch();
|
||||
Router.push('/dashboard');
|
||||
} catch (error: any) {
|
||||
console.log({ error });
|
||||
if (error.message.toLowerCase().includes('otp')) {
|
||||
setOtp(true);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
const [logout] = useAsync(async () => {
|
||||
await http(`auth/logout`, { method: 'POST' });
|
||||
await logoutMutation();
|
||||
resetClient();
|
||||
});
|
||||
|
||||
|
@ -40,6 +45,7 @@ export const useUser = (redirect = false) => {
|
|||
data: user.data?.user,
|
||||
error: user.error,
|
||||
loading: user.loading,
|
||||
otpRequired: otp,
|
||||
login: login,
|
||||
logout: logout,
|
||||
} as const;
|
||||
|
|
|
@ -0,0 +1,167 @@
|
|||
import { Button, ButtonStyle, Container, useAsync, useToasts } from '@ryanke/pandora';
|
||||
import classNames from 'classnames';
|
||||
import { useRouter } from 'next/router';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Copy, Download } from 'react-feather';
|
||||
import { OtpInput } from 'src/components/input/otp';
|
||||
import { PageLoader } from 'src/components/page-loader';
|
||||
import { Steps } from 'src/components/steps';
|
||||
import type { GenerateOtpMutation } from 'src/generated/graphql';
|
||||
import { useConfirmOtpMutation, useGenerateOtpMutation } from 'src/generated/graphql';
|
||||
import { useQueryState } from 'src/hooks/useQueryState';
|
||||
|
||||
export default function Generate() {
|
||||
const [generate] = useGenerateOtpMutation();
|
||||
const [result, setResult] = useState<GenerateOtpMutation | null>(null);
|
||||
const createToast = useToasts();
|
||||
const router = useRouter();
|
||||
const [currentStep, setCurrentStep] = useQueryState('step', 0, (value) => Number(value));
|
||||
const [confirmOtp] = useConfirmOtpMutation();
|
||||
|
||||
useEffect(() => {
|
||||
generate()
|
||||
.then(({ data }) => {
|
||||
if (!data) return;
|
||||
setResult(data);
|
||||
})
|
||||
.catch((error) => {
|
||||
createToast({
|
||||
text: error.message,
|
||||
error: true,
|
||||
});
|
||||
router.push('/dashboard');
|
||||
});
|
||||
}, []);
|
||||
|
||||
const copyable = useMemo(() => {
|
||||
if (!result) return;
|
||||
const prefix = `Use these in place of OTP codes in emergency situations on ${window.location.host}. \nEach code will only work once. If you are close to running out, you should generate new codes.\n\n`;
|
||||
const body = result.generateOTP.recoveryCodes.join('\n');
|
||||
return prefix + body;
|
||||
}, [result]);
|
||||
|
||||
const download = useCallback(() => {
|
||||
if (!copyable) return;
|
||||
const element = document.createElement('a');
|
||||
const file = new Blob([copyable], { type: 'text/plain' });
|
||||
element.href = URL.createObjectURL(file);
|
||||
element.download = `${window.location.host}-recovery-codes.txt`;
|
||||
document.body.append(element);
|
||||
element.click();
|
||||
}, [copyable]);
|
||||
|
||||
const copy = useCallback(() => {
|
||||
if (!copyable) return;
|
||||
navigator.clipboard.writeText(copyable);
|
||||
createToast({
|
||||
text: 'Copied recovery codes!',
|
||||
});
|
||||
}, [createToast, copyable]);
|
||||
|
||||
const [confirm, confirming] = useAsync(async (otpCode: string) => {
|
||||
try {
|
||||
await confirmOtp({ variables: { otpCode } });
|
||||
createToast({ text: 'Successfully enabled 2FA!' });
|
||||
router.replace('/dashboard');
|
||||
} catch (error: any) {
|
||||
if (error.message) {
|
||||
createToast({
|
||||
text: error.message,
|
||||
error: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return <PageLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Steps steps={['Backup', 'Scan', 'Verify']} stepIndex={currentStep} />
|
||||
<div className="max-w-xl mx-auto mt-16">
|
||||
<div className="flex gap-8 md:h-[20em] flex flex-col-reverse md:grid md:grid-cols-6">
|
||||
{currentStep === 0 && (
|
||||
<Fragment>
|
||||
<div className="bg-gray-900 p-4 h-min rounded-xl font-mono whitespace-pre truncate text-gray-600 col-span-2 select-none text-center">
|
||||
{result.generateOTP.recoveryCodes.join('\n')}
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<h2>Store your backup codes</h2>
|
||||
<p className="max-w-xl text-gray-500 text-sm mt-2">
|
||||
You should store these codes somewhere safe. If you lose access to your authenticator, you can use
|
||||
these codes to access your account. Each code will only work once.
|
||||
</p>
|
||||
<div className="flex mt-8 gap-2">
|
||||
<Button style={ButtonStyle.Secondary} className="w-auto" onClick={download}>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Download Codes
|
||||
</Button>
|
||||
<Button style={ButtonStyle.Secondary} className="w-auto" onClick={copy}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
Copy Codes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
{currentStep === 1 && (
|
||||
<Fragment>
|
||||
<div className="p-4 rounded-xl h-min bg-white col-span-2">
|
||||
<QRCodeSVG className="w-full h-full" size={128} value={result.generateOTP.qrauthUrl} />
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<h2>Scan the QR code</h2>
|
||||
<div className="max-w-xl text-gray-500 text-sm mt-2 space-y-2">
|
||||
<p>
|
||||
Scan this QR code with your authenticator app. You can use any authenticator app that supports TOTP,
|
||||
such as Google Authenticator and Authy.
|
||||
</p>
|
||||
<p className="text-xs text-gray-600">
|
||||
If you can't scan the QR code, you can enter the code{' '}
|
||||
<code className="text-purple-400">{result.generateOTP.secret}</code> manually.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<Fragment>
|
||||
<div className="col-span-full">
|
||||
<h2>Verify your code</h2>
|
||||
<p className="max-w-xl text-gray-500 text-sm mt-2">
|
||||
Enter the code from your authenticator app to verify that it is working.
|
||||
</p>
|
||||
<div className="flex mt-4 gap-2">
|
||||
<OtpInput loading={confirming} onCode={confirm} />
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full flex justify-between items-center mt-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentStep((prev) => prev - 1)}
|
||||
disabled={currentStep === 0}
|
||||
className={classNames(
|
||||
`text-gray-400 flex items-center gap-1 hover:underline`,
|
||||
currentStep === 0 && 'opacity-0 pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" /> Back
|
||||
</button>
|
||||
<Button
|
||||
onClick={() => setCurrentStep((prev) => prev + 1)}
|
||||
disabled={currentStep === 2}
|
||||
className="w-auto ml-auto"
|
||||
>
|
||||
Next <ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
|
@ -1,15 +1,18 @@
|
|||
import { Breadcrumbs, Container, useAsync } from '@ryanke/pandora';
|
||||
import { Breadcrumbs, Button, Container, useAsync } from '@ryanke/pandora';
|
||||
import { useRouter } from 'next/router';
|
||||
import { OtpInput } from 'src/components/input/otp';
|
||||
import { Input } from '../../components/input/input';
|
||||
import { PageLoader } from '../../components/page-loader';
|
||||
import { Title } from '../../components/title';
|
||||
import { ConfigGenerator } from '../../containers/config-generator/config-generator';
|
||||
import { useRefreshTokenMutation } from '../../generated/graphql';
|
||||
import { GetUserDocument, useDisableOtpMutation, useRefreshTokenMutation } from '../../generated/graphql';
|
||||
import { useConfig } from '../../hooks/useConfig';
|
||||
import { useUser } from '../../hooks/useUser';
|
||||
|
||||
export default function Preferences() {
|
||||
const user = useUser(true);
|
||||
const config = useConfig();
|
||||
const router = useRouter();
|
||||
const [refreshMutation] = useRefreshTokenMutation();
|
||||
const [refresh, refreshing] = useAsync(async () => {
|
||||
// eslint-disable-next-line no-alert
|
||||
|
@ -19,6 +22,10 @@ export default function Preferences() {
|
|||
await user.logout();
|
||||
});
|
||||
|
||||
const [disableOTP, disableOTPMut] = useDisableOtpMutation({
|
||||
refetchQueries: [{ query: GetUserDocument }],
|
||||
});
|
||||
|
||||
if (!user.data || !config.data) {
|
||||
return <PageLoader title="Preferences" />;
|
||||
}
|
||||
|
@ -53,6 +60,32 @@ export default function Preferences() {
|
|||
<div className="mt-10">
|
||||
<ConfigGenerator />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 mt-8">
|
||||
<div className="left col-span-full md:col-span-1">
|
||||
<div className="font-bold text-xl">2-factor Authentication</div>
|
||||
<p className="text-sm mt-2 text-gray-400">
|
||||
2-factor authentication is currently {user.data.otpEnabled ? 'enabled' : 'disabled'}.{' '}
|
||||
{user.data.otpEnabled ? `Enter an authenticator code to disable it.` : 'Click to setup.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="right flex items-center col-span-full md:col-span-1">
|
||||
{user.data.otpEnabled && (
|
||||
<OtpInput
|
||||
loading={disableOTPMut.loading}
|
||||
onCode={(otpCode) => {
|
||||
disableOTP({
|
||||
variables: { otpCode },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!user.data.otpEnabled && (
|
||||
<Button className="w-auto ml-auto" onClick={() => router.push(`/dashboard/mfa`)}>
|
||||
Enable 2FA
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
|
108
pnpm-lock.yaml
108
pnpm-lock.yaml
|
@ -64,6 +64,7 @@ importers:
|
|||
nanoid: ^3.3.4
|
||||
nodemailer: ^6.7.6
|
||||
normalize-url: ^6
|
||||
otplib: ^12.0.1
|
||||
passport: ^0.6.0
|
||||
passport-jwt: ^4.0.0
|
||||
passport-local: ^1.0.0
|
||||
|
@ -117,6 +118,7 @@ importers:
|
|||
nanoid: 3.3.4
|
||||
nodemailer: 6.7.6
|
||||
normalize-url: 6.1.0
|
||||
otplib: 12.0.1
|
||||
passport: 0.6.0
|
||||
passport-jwt: 4.0.0
|
||||
passport-local: 1.0.0
|
||||
|
@ -204,7 +206,9 @@ importers:
|
|||
nanoid: ^3.3.4
|
||||
next: 12.2.0
|
||||
postcss: ^8.4.13
|
||||
prettier: ^2.7.1
|
||||
prism-react-renderer: ^1.3.5
|
||||
qrcode.react: ^3.1.0
|
||||
react: 18.2.0
|
||||
react-dom: ^18.1.0
|
||||
react-feather: ^2.0.9
|
||||
|
@ -235,6 +239,7 @@ importers:
|
|||
next: 12.2.0_biqbaboplfbrettd7655fr4n2y
|
||||
postcss: 8.4.14
|
||||
prism-react-renderer: 1.3.5_react@18.2.0
|
||||
qrcode.react: 3.1.0_react@18.2.0
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0_react@18.2.0
|
||||
react-feather: 2.0.10_react@18.2.0
|
||||
|
@ -253,6 +258,7 @@ importers:
|
|||
'@types/lodash': 4.14.182
|
||||
'@types/node': 16.11.43
|
||||
'@types/react': 18.0.14
|
||||
prettier: 2.7.1
|
||||
typescript: 4.7.4
|
||||
|
||||
packages:
|
||||
|
@ -1600,6 +1606,7 @@ packages:
|
|||
engines: {node: '>=12'}
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.9
|
||||
dev: true
|
||||
|
||||
/@endemolshinegroup/cosmiconfig-typescript-loader/3.0.2_zmjss6mecb4soo3dpdlecld3xa:
|
||||
resolution: {integrity: sha512-QRVtqJuS1mcT56oHpVegkKBlgtWjXw/gHNWO3eL9oyB5Sc7HBoc2OLG/nYpVfT/Jejvo3NUrD0Udk7XgoyDKkA==}
|
||||
|
@ -2355,7 +2362,6 @@ packages:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
- ts-node
|
||||
dev: true
|
||||
|
||||
/@jest/core/28.1.2_ts-node@10.8.2:
|
||||
resolution: {integrity: sha512-Xo4E+Sb/nZODMGOPt2G3cMmCBqL4/W2Ijwr7/mrXlq4jdJwcFQ/9KrrJZT2adQRk2otVBXXOz1GRQ4Z5iOgvRQ==}
|
||||
|
@ -2398,6 +2404,7 @@ packages:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
- ts-node
|
||||
dev: true
|
||||
|
||||
/@jest/create-cache-key-function/27.5.1:
|
||||
resolution: {integrity: sha512-dmH1yW+makpTSURTy8VzdUwFnfQh1G8R+DxO2Ho2FFmBbKFEVm+3jWdvFhE2VqB/LATCTokkP0dotjyQyw5/AQ==}
|
||||
|
@ -2663,6 +2670,7 @@ packages:
|
|||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.0.8
|
||||
'@jridgewell/sourcemap-codec': 1.4.14
|
||||
dev: true
|
||||
|
||||
/@mikro-orm/cli/5.2.2_4k2cb7nrrwahnwnvazylxfgb44:
|
||||
resolution: {integrity: sha512-IRMcfVFF6edBX2lQsKswMAjw6ILOvwnLT0cMJziknVMuPop/ckaWxlA5r4Uu1BorNtoHf52qB9i3r5YwEe9qAQ==}
|
||||
|
@ -3396,6 +3404,39 @@ packages:
|
|||
- encoding
|
||||
dev: false
|
||||
|
||||
/@otplib/core/12.0.1:
|
||||
resolution: {integrity: sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==}
|
||||
dev: false
|
||||
|
||||
/@otplib/plugin-crypto/12.0.1:
|
||||
resolution: {integrity: sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==}
|
||||
dependencies:
|
||||
'@otplib/core': 12.0.1
|
||||
dev: false
|
||||
|
||||
/@otplib/plugin-thirty-two/12.0.1:
|
||||
resolution: {integrity: sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==}
|
||||
dependencies:
|
||||
'@otplib/core': 12.0.1
|
||||
thirty-two: 1.0.2
|
||||
dev: false
|
||||
|
||||
/@otplib/preset-default/12.0.1:
|
||||
resolution: {integrity: sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==}
|
||||
dependencies:
|
||||
'@otplib/core': 12.0.1
|
||||
'@otplib/plugin-crypto': 12.0.1
|
||||
'@otplib/plugin-thirty-two': 12.0.1
|
||||
dev: false
|
||||
|
||||
/@otplib/preset-v11/12.0.1:
|
||||
resolution: {integrity: sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==}
|
||||
dependencies:
|
||||
'@otplib/core': 12.0.1
|
||||
'@otplib/plugin-crypto': 12.0.1
|
||||
'@otplib/plugin-thirty-two': 12.0.1
|
||||
dev: false
|
||||
|
||||
/@protobufjs/aspromise/1.1.2:
|
||||
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
|
||||
dev: false
|
||||
|
@ -3548,6 +3589,7 @@ packages:
|
|||
cpu: [arm]
|
||||
os: [android]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@swc/core-android-arm64/1.2.204:
|
||||
|
@ -3564,6 +3606,7 @@ packages:
|
|||
cpu: [arm64]
|
||||
os: [android]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@swc/core-darwin-arm64/1.2.204:
|
||||
|
@ -3580,6 +3623,7 @@ packages:
|
|||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@swc/core-darwin-x64/1.2.204:
|
||||
|
@ -3596,6 +3640,7 @@ packages:
|
|||
cpu: [x64]
|
||||
os: [darwin]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@swc/core-freebsd-x64/1.2.204:
|
||||
|
@ -3612,6 +3657,7 @@ packages:
|
|||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@swc/core-linux-arm-gnueabihf/1.2.204:
|
||||
|
@ -3628,6 +3674,7 @@ packages:
|
|||
cpu: [arm]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@swc/core-linux-arm64-gnu/1.2.204:
|
||||
|
@ -3644,6 +3691,7 @@ packages:
|
|||
cpu: [arm64]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@swc/core-linux-arm64-musl/1.2.204:
|
||||
|
@ -3660,6 +3708,7 @@ packages:
|
|||
cpu: [arm64]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@swc/core-linux-x64-gnu/1.2.204:
|
||||
|
@ -3676,6 +3725,7 @@ packages:
|
|||
cpu: [x64]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@swc/core-linux-x64-musl/1.2.204:
|
||||
|
@ -3692,6 +3742,7 @@ packages:
|
|||
cpu: [x64]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@swc/core-win32-arm64-msvc/1.2.204:
|
||||
|
@ -3708,6 +3759,7 @@ packages:
|
|||
cpu: [arm64]
|
||||
os: [win32]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@swc/core-win32-ia32-msvc/1.2.204:
|
||||
|
@ -3724,6 +3776,7 @@ packages:
|
|||
cpu: [ia32]
|
||||
os: [win32]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@swc/core-win32-x64-msvc/1.2.204:
|
||||
|
@ -3740,6 +3793,7 @@ packages:
|
|||
cpu: [x64]
|
||||
os: [win32]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@swc/core/1.2.204:
|
||||
|
@ -3779,6 +3833,7 @@ packages:
|
|||
'@swc/core-win32-arm64-msvc': 1.2.208
|
||||
'@swc/core-win32-ia32-msvc': 1.2.208
|
||||
'@swc/core-win32-x64-msvc': 1.2.208
|
||||
dev: true
|
||||
|
||||
/@swc/helpers/0.4.2:
|
||||
resolution: {integrity: sha512-556Az0VX7WR6UdoTn4htt/l3zPQ7bsQWK+HqdG4swV7beUCxo/BqmvbOpUkTIm/9ih86LIf1qsUnywNL3obGHw==}
|
||||
|
@ -3821,7 +3876,7 @@ packages:
|
|||
eslint: 8.19.0
|
||||
eslint-config-galex: 3.6.5_eslint@8.19.0+jest@28.1.2
|
||||
eslint-plugin-es: 4.1.0_eslint@8.19.0
|
||||
jest: 28.1.2_qv5kk3vgcyi3feqo7l5zvvzluy
|
||||
jest: 28.1.2_@types+node@16.11.43
|
||||
transitivePeerDependencies:
|
||||
- eslint-import-resolver-typescript
|
||||
- eslint-import-resolver-webpack
|
||||
|
@ -3885,15 +3940,19 @@ packages:
|
|||
|
||||
/@tsconfig/node10/1.0.9:
|
||||
resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==}
|
||||
dev: true
|
||||
|
||||
/@tsconfig/node12/1.0.11:
|
||||
resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==}
|
||||
dev: true
|
||||
|
||||
/@tsconfig/node14/1.0.3:
|
||||
resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==}
|
||||
dev: true
|
||||
|
||||
/@tsconfig/node16/1.0.3:
|
||||
resolution: {integrity: sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==}
|
||||
dev: true
|
||||
|
||||
/@types/accepts/1.3.5:
|
||||
resolution: {integrity: sha512-jOdnI/3qTpHABjM5cx1Hc0sKsPoYCp+DP/GJRGtDlPd7fiV9oXGGIcjW/ZOxLIvjGz8MA+uMZI9metHlgqbgwQ==}
|
||||
|
@ -4539,6 +4598,7 @@ packages:
|
|||
/acorn-walk/8.2.0:
|
||||
resolution: {integrity: sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
dev: true
|
||||
|
||||
/acorn/7.4.1:
|
||||
resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==}
|
||||
|
@ -5002,6 +5062,7 @@ packages:
|
|||
|
||||
/arg/4.1.3:
|
||||
resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
|
||||
dev: true
|
||||
|
||||
/arg/5.0.2:
|
||||
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
|
||||
|
@ -6059,6 +6120,7 @@ packages:
|
|||
|
||||
/create-require/1.1.1:
|
||||
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
|
||||
dev: true
|
||||
|
||||
/cron/1.8.2:
|
||||
resolution: {integrity: sha512-Gk2c4y6xKEO8FSAUTklqtfSr7oTq0CiPQeLBG5Fl0qoXpZyMcj1SG59YL+hqq04bu6/IuEA7lMkYDAplQNKkyg==}
|
||||
|
@ -6360,6 +6422,7 @@ packages:
|
|||
/diff/4.0.2:
|
||||
resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
dev: true
|
||||
|
||||
/diff/5.1.0:
|
||||
resolution: {integrity: sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==}
|
||||
|
@ -7000,7 +7063,7 @@ packages:
|
|||
'@typescript-eslint/eslint-plugin': 5.12.0_byxz3mnn3ms3gbtbci7fo4cm6q
|
||||
'@typescript-eslint/utils': 5.29.0_m32fwjepeyylyephxtubzxm4ui
|
||||
eslint: 8.19.0
|
||||
jest: 28.1.2_qv5kk3vgcyi3feqo7l5zvvzluy
|
||||
jest: 28.1.2_@types+node@16.11.43
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
- typescript
|
||||
|
@ -8970,7 +9033,6 @@ packages:
|
|||
- '@types/node'
|
||||
- supports-color
|
||||
- ts-node
|
||||
dev: true
|
||||
|
||||
/jest-cli/28.1.2_qv5kk3vgcyi3feqo7l5zvvzluy:
|
||||
resolution: {integrity: sha512-l6eoi5Do/IJUXAFL9qRmDiFpBeEJAnjJb1dcd9i/VWfVWbp3mJhuH50dNtX67Ali4Ecvt4eBkWb4hXhPHkAZTw==}
|
||||
|
@ -8998,6 +9060,7 @@ packages:
|
|||
- '@types/node'
|
||||
- supports-color
|
||||
- ts-node
|
||||
dev: true
|
||||
|
||||
/jest-config/28.1.2_4maxphccb5fztufhofwcslq6fm:
|
||||
resolution: {integrity: sha512-g6EfeRqddVbjPVBVY4JWpUY4IvQoFRIZcv4V36QkqzE0IGhEC/VkugFeBMAeUE7PRgC8KJF0yvJNDeQRbamEVA==}
|
||||
|
@ -9037,6 +9100,7 @@ packages:
|
|||
ts-node: 10.8.2_pbcylixk5f7tclmtradmulh4qa
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: true
|
||||
|
||||
/jest-config/28.1.2_@types+node@16.11.43:
|
||||
resolution: {integrity: sha512-g6EfeRqddVbjPVBVY4JWpUY4IvQoFRIZcv4V36QkqzE0IGhEC/VkugFeBMAeUE7PRgC8KJF0yvJNDeQRbamEVA==}
|
||||
|
@ -9075,7 +9139,6 @@ packages:
|
|||
strip-json-comments: 3.1.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: true
|
||||
|
||||
/jest-config/28.1.2_qv5kk3vgcyi3feqo7l5zvvzluy:
|
||||
resolution: {integrity: sha512-g6EfeRqddVbjPVBVY4JWpUY4IvQoFRIZcv4V36QkqzE0IGhEC/VkugFeBMAeUE7PRgC8KJF0yvJNDeQRbamEVA==}
|
||||
|
@ -9115,6 +9178,7 @@ packages:
|
|||
ts-node: 10.8.2_pbcylixk5f7tclmtradmulh4qa
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: true
|
||||
|
||||
/jest-diff/28.1.1:
|
||||
resolution: {integrity: sha512-/MUUxeR2fHbqHoMMiffe/Afm+U8U4olFRJ0hiVG2lZatPJcnGxx292ustVu7bULhjV65IYMxRdploAKLbcrsyg==}
|
||||
|
@ -9454,7 +9518,6 @@ packages:
|
|||
- '@types/node'
|
||||
- supports-color
|
||||
- ts-node
|
||||
dev: true
|
||||
|
||||
/jest/28.1.2_qv5kk3vgcyi3feqo7l5zvvzluy:
|
||||
resolution: {integrity: sha512-Tuf05DwLeCh2cfWCQbcz9UxldoDyiR1E9Igaei5khjonKncYdc6LDfynKCEWozK0oLE3GD+xKAo2u8x/0s6GOg==}
|
||||
|
@ -9474,6 +9537,7 @@ packages:
|
|||
- '@types/node'
|
||||
- supports-color
|
||||
- ts-node
|
||||
dev: true
|
||||
|
||||
/joycon/3.1.1:
|
||||
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
|
||||
|
@ -10003,6 +10067,7 @@ packages:
|
|||
|
||||
/make-error/1.3.6:
|
||||
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
|
||||
dev: true
|
||||
|
||||
/make-fetch-happen/8.0.14:
|
||||
resolution: {integrity: sha512-EsS89h6l4vbfJEtBZnENTOFk8mCRpY5ru36Xe5bcX1KYIli2mkSHqoFsp5O1wMDvTJJzxe/4THpCTtygjeeGWQ==}
|
||||
|
@ -11065,6 +11130,14 @@ packages:
|
|||
engines: {node: '>=0.10.0'}
|
||||
dev: true
|
||||
|
||||
/otplib/12.0.1:
|
||||
resolution: {integrity: sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==}
|
||||
dependencies:
|
||||
'@otplib/core': 12.0.1
|
||||
'@otplib/preset-default': 12.0.1
|
||||
'@otplib/preset-v11': 12.0.1
|
||||
dev: false
|
||||
|
||||
/p-cancelable/1.1.0:
|
||||
resolution: {integrity: sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==}
|
||||
engines: {node: '>=6'}
|
||||
|
@ -11552,6 +11625,12 @@ packages:
|
|||
engines: {node: '>=4'}
|
||||
dev: true
|
||||
|
||||
/prettier/2.7.1:
|
||||
resolution: {integrity: sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/pretty-bytes/6.0.0:
|
||||
resolution: {integrity: sha512-6UqkYefdogmzqAZWzJ7laYeJnaXDy2/J+ZqiiMtS7t7OfpXWTlaeGMwX8U6EFvPV/YWWEKRkS8hKS4k60WHTOg==}
|
||||
engines: {node: ^14.13.1 || >=16.0.0}
|
||||
|
@ -11667,6 +11746,14 @@ packages:
|
|||
engines: {node: '>= 14'}
|
||||
dev: false
|
||||
|
||||
/qrcode.react/3.1.0_react@18.2.0:
|
||||
resolution: {integrity: sha512-oyF+Urr3oAMUG/OiOuONL3HXM+53wvuH3mtIWQrYmsXoAq0DkvZp2RYUWFSMFtbdOpuS++9v+WAkzNVkMlNW6Q==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/queue-microtask/1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
|
@ -12925,6 +13012,11 @@ packages:
|
|||
any-promise: 1.3.0
|
||||
dev: true
|
||||
|
||||
/thirty-two/1.0.2:
|
||||
resolution: {integrity: sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==}
|
||||
engines: {node: '>=0.2.6'}
|
||||
dev: false
|
||||
|
||||
/throat/6.0.1:
|
||||
resolution: {integrity: sha512-8hmiGIJMDlwjg7dlJ4yKGLK8EsYqKgPWbG3b4wjJddKNwc7N7Dpn08Df4szr/sZdMVeOstrdYSsqzX6BYbcB+w==}
|
||||
|
||||
|
@ -13104,6 +13196,7 @@ packages:
|
|||
typescript: 4.7.4
|
||||
v8-compile-cache-lib: 3.0.1
|
||||
yn: 3.1.1
|
||||
dev: true
|
||||
|
||||
/ts-node/9.1.1_typescript@4.7.4:
|
||||
resolution: {integrity: sha512-hPlt7ZACERQGf03M253ytLY3dHbGNGrAq9qIHWUY9XHYl1z7wYngSr3OQ5xmui8o2AaxsONxIzjafLUiWBo1Fg==}
|
||||
|
@ -13420,6 +13513,7 @@ packages:
|
|||
resolution: {integrity: sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==}
|
||||
engines: {node: '>=4.2.0'}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/ua-parser-js/0.7.31:
|
||||
resolution: {integrity: sha512-qLK/Xe9E2uzmYI3qLeOmI0tEOt+TBBQyUIAh4aAgU05FVYzeZrKUdkAZfBNVGRaHVgV0TDkdEngJSw/SyQchkQ==}
|
||||
|
@ -13652,6 +13746,7 @@ packages:
|
|||
|
||||
/v8-compile-cache-lib/3.0.1:
|
||||
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
|
||||
dev: true
|
||||
|
||||
/v8-compile-cache/2.3.0:
|
||||
resolution: {integrity: sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==}
|
||||
|
@ -13999,6 +14094,7 @@ packages:
|
|||
/yn/3.1.1:
|
||||
resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}
|
||||
engines: {node: '>=6'}
|
||||
dev: true
|
||||
|
||||
/yocto-queue/0.1.0:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
|
|
Loading…
Reference in New Issue