mirror of https://github.com/sylv/micro.git
Compare commits
2 Commits
e56d700d2e
...
a865996f31
Author | SHA1 | Date |
---|---|---|
Sylver | a865996f31 | |
Sylver | db4728f1f7 |
|
@ -1,5 +1,4 @@
|
|||
import { loadConfig } from '@ryanke/venera';
|
||||
import bytes from 'bytes';
|
||||
import c from 'chalk';
|
||||
import { randomBytes } from 'crypto';
|
||||
import dedent from 'dedent';
|
||||
|
@ -9,6 +8,7 @@ import z, { any, array, boolean, number, record, strictObject, string, union } f
|
|||
import { fromZodError } from 'zod-validation-error';
|
||||
import { expandMime } from './helpers/expand-mime.js';
|
||||
import { HostService } from './modules/host/host.service.js';
|
||||
import { parseBytes } from './helpers/parse-bytes.js';
|
||||
|
||||
export type MicroHost = ReturnType<typeof enhanceHost>;
|
||||
|
||||
|
@ -16,7 +16,7 @@ const schema = strictObject({
|
|||
databaseUrl: string().startsWith('postgresql://'),
|
||||
secret: string().min(6),
|
||||
inquiries: string().email(),
|
||||
uploadLimit: string().transform(bytes.parse),
|
||||
uploadLimit: string().transform(parseBytes),
|
||||
maxPasteLength: number().default(500000),
|
||||
allowTypes: z
|
||||
.union([array(string()), string()])
|
||||
|
@ -25,7 +25,7 @@ const schema = strictObject({
|
|||
storagePath: string(),
|
||||
restrictFilesToHost: boolean().default(true),
|
||||
purge: strictObject({
|
||||
overLimit: string().transform(bytes.parse),
|
||||
overLimit: string().transform(parseBytes),
|
||||
afterTime: string().transform(ms),
|
||||
}).optional(),
|
||||
email: strictObject({
|
||||
|
@ -36,7 +36,7 @@ const schema = strictObject({
|
|||
strictObject({
|
||||
from: union([array(string()), string()]).transform((value) => new Set(expandMime(value))),
|
||||
to: string(),
|
||||
minSize: string().transform(bytes.parse).optional(),
|
||||
minSize: string().transform(parseBytes).optional(),
|
||||
}),
|
||||
).optional(),
|
||||
hosts: array(
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
const UNITS = new Map<string, number>([
|
||||
['B', 1],
|
||||
['KB', 1024],
|
||||
['K', 1024],
|
||||
['MB', 1024 * 1024],
|
||||
['M', 1024 * 1024],
|
||||
['GB', 1024 * 1024 * 1024],
|
||||
['G', 1024 * 1024 * 1024],
|
||||
]);
|
||||
|
||||
const PATTERN = new RegExp(`(?<value>[0-9]+(?:\\.[0-9]+)?)\\s*(?<unit>${[...UNITS.keys()].join('|')})`, 'i')
|
||||
|
||||
export const parseBytes = (input:string) => {
|
||||
const match = input.match(PATTERN)
|
||||
if (!match) {
|
||||
throw new Error('Invalid byte format')
|
||||
}
|
||||
|
||||
const value = +match.groups!.value
|
||||
const unit = match.groups!.unit.toUpperCase()
|
||||
const unitMultiplier = UNITS.get(unit)
|
||||
|
||||
if (!unitMultiplier) {
|
||||
throw new Error('Invalid byte format')
|
||||
}
|
||||
|
||||
return value * unitMultiplier
|
||||
}
|
|
@ -116,6 +116,15 @@
|
|||
"primary": false,
|
||||
"nullable": true,
|
||||
"mappedType": "array"
|
||||
},
|
||||
"disabled_reason": {
|
||||
"name": "disabled_reason",
|
||||
"type": "varchar(255)",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": true,
|
||||
"mappedType": "string"
|
||||
}
|
||||
},
|
||||
"name": "users",
|
||||
|
@ -285,7 +294,7 @@
|
|||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": true,
|
||||
"length": 6,
|
||||
"length": 0,
|
||||
"mappedType": "datetime"
|
||||
},
|
||||
"created_at": {
|
||||
|
@ -295,7 +304,7 @@
|
|||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"length": 6,
|
||||
"length": 0,
|
||||
"mappedType": "datetime"
|
||||
},
|
||||
"owner_id": {
|
||||
|
@ -385,7 +394,7 @@
|
|||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"length": 6,
|
||||
"length": 0,
|
||||
"mappedType": "datetime"
|
||||
},
|
||||
"owner_id": {
|
||||
|
@ -472,7 +481,7 @@
|
|||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"length": 6,
|
||||
"length": 0,
|
||||
"mappedType": "datetime"
|
||||
},
|
||||
"skip_verification": {
|
||||
|
@ -492,7 +501,7 @@
|
|||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": true,
|
||||
"length": 6,
|
||||
"length": 0,
|
||||
"mappedType": "datetime"
|
||||
}
|
||||
},
|
||||
|
@ -595,6 +604,15 @@
|
|||
"nullable": false,
|
||||
"mappedType": "string"
|
||||
},
|
||||
"is_utf8": {
|
||||
"name": "is_utf8",
|
||||
"type": "boolean",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": true,
|
||||
"mappedType": "boolean"
|
||||
},
|
||||
"metadata_height": {
|
||||
"name": "metadata_height",
|
||||
"type": "int",
|
||||
|
@ -647,7 +665,7 @@
|
|||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"length": 6,
|
||||
"length": 0,
|
||||
"mappedType": "datetime"
|
||||
},
|
||||
"owner_id": {
|
||||
|
@ -792,7 +810,7 @@
|
|||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"length": 6,
|
||||
"length": 0,
|
||||
"mappedType": "datetime"
|
||||
}
|
||||
},
|
||||
|
@ -853,7 +871,7 @@
|
|||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"length": 6,
|
||||
"length": 0,
|
||||
"mappedType": "datetime"
|
||||
}
|
||||
},
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
export class Migration20240506030901 extends Migration {
|
||||
|
||||
async up(): Promise<void> {
|
||||
this.addSql('alter table "users" add column "disabled_reason" varchar(255) null;');
|
||||
}
|
||||
|
||||
async down(): Promise<void> {
|
||||
this.addSql('alter table "users" drop column "disabled_reason";');
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import { UnauthorizedException } from '@nestjs/common';
|
||||
|
||||
export class AccountDisabledError extends UnauthorizedException {
|
||||
constructor(message: string) {
|
||||
super({
|
||||
// nestjs will filter out any additional keys we add to this object for graphql.
|
||||
// unfortunately, the only way for the frontend to pick this up without rewriting
|
||||
// nestjs error handling is to append the type to the message.
|
||||
message: `ACCOUNT_DISABLED: ${message}`,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -7,6 +7,7 @@ import crypto from 'crypto';
|
|||
import { authenticator } from 'otplib';
|
||||
import { User } from '../user/user.entity.js';
|
||||
import type { OTPEnabledDto } from './dto/otp-enabled.dto.js';
|
||||
import { AccountDisabledError } from './account-disabled.error.js';
|
||||
|
||||
export enum TokenType {
|
||||
USER = 'USER',
|
||||
|
@ -68,6 +69,10 @@ export class AuthService {
|
|||
await this.validateOTPCode(otpCode, user);
|
||||
}
|
||||
|
||||
if (user.disabledReason) {
|
||||
throw new AccountDisabledError(user.disabledReason)
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ import { Reflector } from '@nestjs/core';
|
|||
import { Permission } from '../../../constants.js';
|
||||
import { getRequest } from '../../../helpers/get-request.js';
|
||||
import { UserService } from '../../user/user.service.js';
|
||||
import { AccountDisabledError } from '../account-disabled.error.js';
|
||||
|
||||
@Injectable()
|
||||
export class PermissionGuard implements CanActivate {
|
||||
|
@ -17,6 +18,10 @@ export class PermissionGuard implements CanActivate {
|
|||
const userId = request.user.id;
|
||||
const user = await this.userService.getUser(userId, false);
|
||||
if (!user) return false;
|
||||
if (user.disabledReason) {
|
||||
throw new AccountDisabledError(user.disabledReason)
|
||||
}
|
||||
|
||||
if (this.userService.checkPermissions(user.permissions, Permission.ADMINISTRATOR)) return true;
|
||||
if (!this.userService.checkPermissions(user.permissions, requiredPermissions)) return false;
|
||||
return true;
|
||||
|
|
|
@ -7,6 +7,7 @@ import { Strategy } from 'passport-jwt';
|
|||
import { config } from '../../../config.js';
|
||||
import { User } from '../../user/user.entity.js';
|
||||
import { TokenType } from '../auth.service.js';
|
||||
import { AccountDisabledError } from '../account-disabled.error.js';
|
||||
|
||||
export interface JWTPayloadUser {
|
||||
id: string;
|
||||
|
@ -33,6 +34,10 @@ export class JWTStrategy extends PassportStrategy(Strategy) {
|
|||
if (!payload.secret) throw new UnauthorizedException('Outdated JWT - try refresh your session');
|
||||
const user = await this.userRepo.findOne({ secret: payload.secret });
|
||||
if (!user) throw new UnauthorizedException('Invalid token secret');
|
||||
if (user.disabledReason) {
|
||||
throw new AccountDisabledError(user.disabledReason)
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -80,5 +80,9 @@ export class User {
|
|||
@Property({ nullable: true, hidden: true, type: ArrayType })
|
||||
otpRecoveryCodes?: string[];
|
||||
|
||||
@Exclude()
|
||||
@Property({ hidden: true, nullable: true })
|
||||
disabledReason?: string;
|
||||
|
||||
[OptionalProps]: 'permissions' | 'tags' | 'verifiedEmail';
|
||||
}
|
||||
|
|
|
@ -23,13 +23,13 @@ const documents = {
|
|||
types.GetFilesDocument,
|
||||
'\n query GetPastes($after: String) {\n user {\n pastes(first: 24, after: $after) {\n pageInfo {\n endCursor\n hasNextPage\n }\n edges {\n node {\n id\n ...PasteCard\n }\n }\n }\n }\n }\n':
|
||||
types.GetPastesDocument,
|
||||
'\n mutation Login($username: String!, $password: String!, $otp: String) {\n login(username: $username, password: $password, otpCode: $otp) {\n ...RegularUser\n }\n }\n':
|
||||
types.LoginDocument,
|
||||
'\n query Config {\n config {\n allowTypes\n inquiriesEmail\n requireEmails\n uploadLimit\n currentHost {\n normalised\n redirect\n }\n rootHost {\n normalised\n url\n }\n hosts {\n normalised\n }\n }\n }\n':
|
||||
types.ConfigDocument,
|
||||
'\n fragment RegularUser on User {\n id\n username\n email\n verifiedEmail\n }\n':
|
||||
types.RegularUserFragmentDoc,
|
||||
'\n query GetUser {\n user {\n ...RegularUser\n }\n }\n': types.GetUserDocument,
|
||||
'\n mutation Login($username: String!, $password: String!, $otp: String) {\n login(username: $username, password: $password, otpCode: $otp) {\n ...RegularUser\n }\n }\n':
|
||||
types.LoginDocument,
|
||||
'\n mutation Logout {\n logout\n }\n': types.LogoutDocument,
|
||||
'\n query GenerateOTP {\n generateOTP {\n recoveryCodes\n qrauthUrl\n secret\n }\n }\n':
|
||||
types.GenerateOtpDocument,
|
||||
|
@ -100,6 +100,12 @@ export function graphql(
|
|||
export function graphql(
|
||||
source: '\n query GetPastes($after: String) {\n user {\n pastes(first: 24, after: $after) {\n pageInfo {\n endCursor\n hasNextPage\n }\n edges {\n node {\n id\n ...PasteCard\n }\n }\n }\n }\n }\n',
|
||||
): (typeof documents)['\n query GetPastes($after: String) {\n user {\n pastes(first: 24, after: $after) {\n pageInfo {\n endCursor\n hasNextPage\n }\n edges {\n node {\n id\n ...PasteCard\n }\n }\n }\n }\n }\n'];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(
|
||||
source: '\n mutation Login($username: String!, $password: String!, $otp: String) {\n login(username: $username, password: $password, otpCode: $otp) {\n ...RegularUser\n }\n }\n',
|
||||
): (typeof documents)['\n mutation Login($username: String!, $password: String!, $otp: String) {\n login(username: $username, password: $password, otpCode: $otp) {\n ...RegularUser\n }\n }\n'];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
|
@ -118,12 +124,6 @@ export function graphql(
|
|||
export function graphql(
|
||||
source: '\n query GetUser {\n user {\n ...RegularUser\n }\n }\n',
|
||||
): (typeof documents)['\n query GetUser {\n user {\n ...RegularUser\n }\n }\n'];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(
|
||||
source: '\n mutation Login($username: String!, $password: String!, $otp: String) {\n login(username: $username, password: $password, otpCode: $otp) {\n ...RegularUser\n }\n }\n',
|
||||
): (typeof documents)['\n mutation Login($username: String!, $password: String!, $otp: String) {\n login(username: $username, password: $password, otpCode: $otp) {\n ...RegularUser\n }\n }\n'];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
|
|
|
@ -377,6 +377,17 @@ export type GetPastesQuery = {
|
|||
};
|
||||
};
|
||||
|
||||
export type LoginMutationVariables = Exact<{
|
||||
username: Scalars['String']['input'];
|
||||
password: Scalars['String']['input'];
|
||||
otp?: InputMaybe<Scalars['String']['input']>;
|
||||
}>;
|
||||
|
||||
export type LoginMutation = {
|
||||
__typename?: 'Mutation';
|
||||
login: { __typename?: 'User'; id: string; username: string; email?: string | null; verifiedEmail: boolean };
|
||||
};
|
||||
|
||||
export type ConfigQueryVariables = Exact<{ [key: string]: never }>;
|
||||
|
||||
export type ConfigQuery = {
|
||||
|
@ -408,17 +419,6 @@ export type GetUserQuery = {
|
|||
user: { __typename?: 'User'; id: string; username: string; email?: string | null; verifiedEmail: boolean };
|
||||
};
|
||||
|
||||
export type LoginMutationVariables = Exact<{
|
||||
username: Scalars['String']['input'];
|
||||
password: Scalars['String']['input'];
|
||||
otp?: InputMaybe<Scalars['String']['input']>;
|
||||
}>;
|
||||
|
||||
export type LoginMutation = {
|
||||
__typename?: 'Mutation';
|
||||
login: { __typename?: 'User'; id: string; username: string; email?: string | null; verifiedEmail: boolean };
|
||||
};
|
||||
|
||||
export type LogoutMutationVariables = Exact<{ [key: string]: never }>;
|
||||
|
||||
export type LogoutMutation = { __typename?: 'Mutation'; logout: boolean };
|
||||
|
@ -915,6 +915,77 @@ export const GetPastesDocument = {
|
|||
},
|
||||
],
|
||||
} as unknown as DocumentNode<GetPastesQuery, GetPastesQueryVariables>;
|
||||
export const LoginDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'Login' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: { kind: 'Variable', name: { kind: 'Name', value: 'username' } },
|
||||
type: { kind: 'NonNullType', type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } } },
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: { kind: 'Variable', name: { kind: 'Name', value: 'password' } },
|
||||
type: { kind: 'NonNullType', type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } } },
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: { kind: 'Variable', name: { kind: 'Name', value: 'otp' } },
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'login' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'username' },
|
||||
value: { kind: 'Variable', name: { kind: 'Name', value: 'username' } },
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'password' },
|
||||
value: { kind: 'Variable', name: { kind: 'Name', value: 'password' } },
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'otpCode' },
|
||||
value: { kind: 'Variable', name: { kind: 'Name', value: 'otp' } },
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [{ kind: 'FragmentSpread', name: { kind: 'Name', value: 'RegularUser' } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'RegularUser' },
|
||||
typeCondition: { kind: 'NamedType', name: { kind: 'Name', value: 'User' } },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'username' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'email' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'verifiedEmail' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<LoginMutation, LoginMutationVariables>;
|
||||
export const ConfigDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
|
@ -1010,77 +1081,6 @@ export const GetUserDocument = {
|
|||
},
|
||||
],
|
||||
} as unknown as DocumentNode<GetUserQuery, GetUserQueryVariables>;
|
||||
export const LoginDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'Login' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: { kind: 'Variable', name: { kind: 'Name', value: 'username' } },
|
||||
type: { kind: 'NonNullType', type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } } },
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: { kind: 'Variable', name: { kind: 'Name', value: 'password' } },
|
||||
type: { kind: 'NonNullType', type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } } },
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: { kind: 'Variable', name: { kind: 'Name', value: 'otp' } },
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'login' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'username' },
|
||||
value: { kind: 'Variable', name: { kind: 'Name', value: 'username' } },
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'password' },
|
||||
value: { kind: 'Variable', name: { kind: 'Name', value: 'password' } },
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'otpCode' },
|
||||
value: { kind: 'Variable', name: { kind: 'Name', value: 'otp' } },
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [{ kind: 'FragmentSpread', name: { kind: 'Name', value: 'RegularUser' } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'RegularUser' },
|
||||
typeCondition: { kind: 'NamedType', name: { kind: 'Name', value: 'User' } },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'username' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'email' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'verifiedEmail' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<LoginMutation, LoginMutationVariables>;
|
||||
export const LogoutDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
|
|
|
@ -1,15 +1,32 @@
|
|||
import clsx from 'clsx';
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { FiInfo } from 'react-icons/fi';
|
||||
import { useMemo, type FC, type ReactNode } from 'react';
|
||||
import { FiInfo, FiXOctagon } from 'react-icons/fi';
|
||||
|
||||
export enum WarningType {
|
||||
Info = 'bg-purple-400 border-purple-400',
|
||||
Error = 'bg-red-500 border-red-500',
|
||||
}
|
||||
|
||||
export const Warning: FC<{ children: ReactNode; type?: WarningType; className?: string }> = ({
|
||||
children,
|
||||
type = WarningType.Info,
|
||||
className,
|
||||
}) => {
|
||||
const classes = clsx('bg-opacity-40 border px-2 py-1 rounded text-sm flex items-center gap-2', className, type);
|
||||
const icon = useMemo(() => {
|
||||
switch (type) {
|
||||
case WarningType.Error: {
|
||||
return <FiXOctagon className="text-red-400 h-5 w-5" />;
|
||||
}
|
||||
case WarningType.Info: {
|
||||
return <FiInfo className="text-purple-400 h-5 w-5" />;
|
||||
}
|
||||
}
|
||||
}, [type]);
|
||||
|
||||
export const Warning: FC<{ children: ReactNode; className?: string }> = ({ children, className }) => {
|
||||
const classes = clsx(
|
||||
'bg-purple-400 bg-opacity-40 border border-purple-400 px-2 py-1 rounded text-sm flex items-center gap-2',
|
||||
className,
|
||||
);
|
||||
return (
|
||||
<div className={classes} role="alert">
|
||||
<FiInfo className="text-purple-400 h-5 w-5" />
|
||||
{icon}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -10,17 +10,32 @@ import { Submit } from '../components/input/submit';
|
|||
import { navigate } from '../helpers/routing';
|
||||
import { useAsync } from '../hooks/useAsync';
|
||||
import { useUser } from '../hooks/useUser';
|
||||
import { useMutation } from '@urql/preact';
|
||||
import { graphql } from '../@generated';
|
||||
import { getErrorMessage } from '../helpers/get-error-message.helper';
|
||||
import { Warning, WarningType } from '../components/warning';
|
||||
|
||||
const schema = Yup.object().shape({
|
||||
username: Yup.string().required().min(2),
|
||||
password: Yup.string().required().min(5),
|
||||
});
|
||||
|
||||
const LoginMutation = graphql(`
|
||||
mutation Login($username: String!, $password: String!, $otp: String) {
|
||||
login(username: $username, password: $password, otpCode: $otp) {
|
||||
...RegularUser
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const LoginForm: FC = () => {
|
||||
const user = useUser();
|
||||
const [loginInfo, setLoginInfo] = useState<LoginMutationVariables | null>(null);
|
||||
const [invalidOTP, setInvalidOTP] = useState(false);
|
||||
const [disabledReason, setDisabledReason] = useState<string | null>(null);
|
||||
const [otpRequired, setOtpRequired] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [, loginMutation] = useMutation(LoginMutation);
|
||||
const redirect = useCallback(() => {
|
||||
const url = new URL(window.location.href);
|
||||
const to = url.searchParams.get('to') ?? '/dashboard';
|
||||
|
@ -35,26 +50,51 @@ export const LoginForm: FC = () => {
|
|||
}, [user, redirect]);
|
||||
|
||||
const [login, loggingIn] = useAsync(async (values: LoginMutationVariables) => {
|
||||
try {
|
||||
setLoginInfo(values);
|
||||
setInvalidOTP(false);
|
||||
await user.login(values);
|
||||
setError(null);
|
||||
redirect();
|
||||
} catch (error: any) {
|
||||
if (user.otpRequired && error.message.toLowerCase().includes('invalid otp')) {
|
||||
setInvalidOTP(true);
|
||||
return;
|
||||
} else if (error.message.toLowerCase().includes('unauthorized')) {
|
||||
setError('Invalid username or password');
|
||||
return;
|
||||
setLoginInfo(values);
|
||||
setInvalidOTP(false);
|
||||
|
||||
// i truly do not understand why this doesn't just throw.
|
||||
const result = await loginMutation(values);
|
||||
if (result.error) {
|
||||
if (result.error.message.toLowerCase().includes('otp')) {
|
||||
setOtpRequired(true);
|
||||
}
|
||||
|
||||
throw error;
|
||||
if (otpRequired && result.error.message.toLowerCase().includes('invalid otp')) {
|
||||
setInvalidOTP(true);
|
||||
} else if (result.error.message.includes('ACCOUNT_DISABLED')) {
|
||||
const index = result.error.message.indexOf(':');
|
||||
const message = result.error.message.slice(index + 1);
|
||||
setDisabledReason(message);
|
||||
} else if (result.error.message.toLowerCase().includes('unauthorized')) {
|
||||
setError('Invalid username or password');
|
||||
} else if (result.error.message.startsWith('ACCOUNT_DISABLED:')) {
|
||||
const message = result.error.message.replace('ACCOUNT_DISABLED: ', '');
|
||||
setError(message);
|
||||
} else {
|
||||
const message = getErrorMessage(result.error);
|
||||
setError(message || 'An unknown error occured.');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
redirect();
|
||||
});
|
||||
|
||||
if (user.otpRequired && loginInfo) {
|
||||
if (disabledReason) {
|
||||
return (
|
||||
<Warning type={WarningType.Error} className="w-full">
|
||||
<div>
|
||||
<h3>Your account is disabled</h3>
|
||||
<p className="font-mono">{disabledReason}</p>
|
||||
</div>
|
||||
</Warning>
|
||||
);
|
||||
}
|
||||
|
||||
if (otpRequired && loginInfo) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<OtpInput
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import type { CombinedError, TypedDocumentNode } from '@urql/preact';
|
||||
import { useMutation, useQuery } from '@urql/preact';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { graphql } from '../@generated/gql';
|
||||
import type { GetUserQuery, LoginMutationVariables } from '../@generated/graphql';
|
||||
import type { GetUserQuery } from '../@generated/graphql';
|
||||
import { type RegularUserFragment } from '../@generated/graphql';
|
||||
import { navigate } from '../helpers/routing';
|
||||
import { useAsync } from './useAsync';
|
||||
|
@ -24,42 +24,12 @@ const UserQuery = graphql(`
|
|||
}
|
||||
`);
|
||||
|
||||
const LoginMutation = graphql(`
|
||||
mutation Login($username: String!, $password: String!, $otp: String) {
|
||||
login(username: $username, password: $password, otpCode: $otp) {
|
||||
...RegularUser
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const LogoutMutation = graphql(`
|
||||
mutation Logout {
|
||||
logout
|
||||
}
|
||||
`);
|
||||
|
||||
export const useLoginUser = () => {
|
||||
const [otp, setOtp] = useState(false);
|
||||
const [, loginMutation] = useMutation(LoginMutation);
|
||||
const [login] = useAsync(async (variables: LoginMutationVariables) => {
|
||||
const result = await loginMutation(variables);
|
||||
if (result.data) {
|
||||
navigate('/dashboard');
|
||||
} else if (result.error) {
|
||||
if (result.error.message.toLowerCase().includes('otp')) {
|
||||
setOtp(true);
|
||||
}
|
||||
|
||||
throw result.error;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
login,
|
||||
otpRequired: otp,
|
||||
};
|
||||
};
|
||||
|
||||
export const useLogoutUser = () => {
|
||||
const [, logoutMutation] = useMutation(LogoutMutation);
|
||||
const [logout] = useAsync(async () => {
|
||||
|
@ -82,7 +52,6 @@ export const useUserRedirect = (
|
|||
};
|
||||
|
||||
export const useUser = <T extends TypedDocumentNode<GetUserQuery, any>>(redirect?: boolean, query?: T) => {
|
||||
const { login, otpRequired } = useLoginUser();
|
||||
const { logout } = useLogoutUser();
|
||||
const [{ data, fetching, error }] = useQuery({ query: (query || UserQuery) as T });
|
||||
|
||||
|
@ -92,8 +61,6 @@ export const useUser = <T extends TypedDocumentNode<GetUserQuery, any>>(redirect
|
|||
data: data?.user as RegularUserFragment | null | undefined,
|
||||
fetching: fetching,
|
||||
error: error as CombinedError | undefined,
|
||||
otpRequired: otpRequired,
|
||||
login: login,
|
||||
logout: logout,
|
||||
} as const;
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue