Skip to content

Commit

Permalink
feat: password hint (#225)
Browse files Browse the repository at this point in the history
* feat: password hint

* fix(ui): wording

* fix(idp): send proper user message in error

* fix: apply code review suggestions
  • Loading branch information
bouassaba authored Jul 25, 2024
1 parent 1b676c9 commit 07308f7
Show file tree
Hide file tree
Showing 9 changed files with 202 additions and 13 deletions.
7 changes: 7 additions & 0 deletions idp/.env
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ TOKEN_ISSUER="localhost"
TOKEN_ACCESS_TOKEN_LIFETIME=86400
TOKEN_REFRESH_TOKEN_LIFETIME=2592000

# Password
PASSWORD_MIN_LENGTH=8
PASSWORD_MIN_LOWERCASE=1
PASSWORD_MIN_UPPERCASE=1
PASSWORD_MIN_NUMBERS=1
PASSWORD_MIN_SYMBOLS=1

# CORS
CORS_ORIGINS="http://127.0.0.1:3000"

Expand Down
17 changes: 15 additions & 2 deletions idp/src/account/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
// the Business Source License, use of this software will be governed
// by the GNU Affero General Public License v3.0 only, included in the file
// licenses/AGPL.txt.

import { Router, Request, Response, NextFunction } from 'express'
import { body, validationResult } from 'express-validator'
import { getConfig } from '@/config/config'
import { parseValidationError } from '@/infra/error'
import {
confirmEmail,
Expand All @@ -20,14 +20,23 @@ import {
AccountCreateOptions,
AccountResetPasswordOptions,
AccountSendResetPasswordEmailOptions,
getPasswordRequirements,
} from './service'

const router = Router()

router.post(
'/',
body('email').isEmail().isLength({ max: 255 }),
body('password').isStrongPassword().isLength({ max: 10000 }),
body('password')
.isStrongPassword({
minLength: getConfig().password.minLength,
minLowercase: getConfig().password.minLowercase,
minUppercase: getConfig().password.minUppercase,
minNumbers: getConfig().password.minNumbers,
minSymbols: getConfig().password.minSymbols,
})
.isLength({ max: 10000 }),
body('fullName').isString().notEmpty().trim().escape().isLength({ max: 255 }),
body('picture').optional().isBase64().isByteLength({ max: 3000000 }),
async (req: Request, res: Response, next: NextFunction) => {
Expand All @@ -43,6 +52,10 @@ router.post(
},
)

router.get('/password_requirements', async (_: Request, res: Response) => {
res.json(getPasswordRequirements())
})

router.post(
'/reset_password',
body('token').isString().notEmpty().trim(),
Expand Down
19 changes: 18 additions & 1 deletion idp/src/account/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
// the Business Source License, use of this software will be governed
// by the GNU Affero General Public License v3.0 only, included in the file
// licenses/AGPL.txt.

import { getConfig } from '@/config/config'
import { newDateTime } from '@/infra/date-time'
import { ErrorCode, newError } from '@/infra/error'
Expand Down Expand Up @@ -39,6 +38,14 @@ export type AccountSendResetPasswordEmailOptions = {
email: string
}

export type PasswordRequirements = {
minLength: number
minLowercase: number
minUppercase: number
minNumbers: number
minSymbols: number
}

export async function createUser(
options: AccountCreateOptions,
): Promise<UserDTO> {
Expand Down Expand Up @@ -127,3 +134,13 @@ export async function sendResetPasswordEmail(
throw newError({ code: ErrorCode.InternalServerError, error })
}
}

export function getPasswordRequirements(): PasswordRequirements {
return {
minLength: getConfig().password.minLength,
minLowercase: getConfig().password.minLowercase,
minUppercase: getConfig().password.minUppercase,
minNumbers: getConfig().password.minNumbers,
minSymbols: getConfig().password.minSymbols,
} as PasswordRequirements
}
11 changes: 10 additions & 1 deletion idp/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// the Business Source License, use of this software will be governed
// by the GNU Affero General Public License v3.0 only, included in the file
// licenses/AGPL.txt.

import * as process from 'node:process'
import { Config } from './types'

let config: Config
Expand All @@ -18,6 +18,7 @@ export function getConfig(): Config {
config.port = parseInt(process.env.PORT)
readURLs(config)
readToken(config)
readPassword(config)
readCORS(config)
readSearch(config)
readSMTP(config)
Expand Down Expand Up @@ -46,6 +47,14 @@ export function readToken(config: Config) {
}
}

export function readPassword(config: Config) {
config.password.minLength = parseInt(process.env.PASSWORD_MIN_LENGTH)
config.password.minLowercase = parseInt(process.env.PASSWORD_MIN_LOWERCASE)
config.password.minUppercase = parseInt(process.env.PASSWORD_MIN_UPPERCASE)
config.password.minNumbers = parseInt(process.env.PASSWORD_MIN_NUMBERS)
config.password.minSymbols = parseInt(process.env.PASSWORD_MIN_SYMBOLS)
}

export function readCORS(config: Config) {
if (process.env.CORS_ORIGINS) {
config.corsOrigins = process.env.CORS_ORIGINS.split(',')
Expand Down
14 changes: 10 additions & 4 deletions idp/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,19 @@ export class Config {
publicUIURL: string
databaseURL: string
token: TokenConfig
password: PasswordConfig
corsOrigins: string[]
search: SearchConfig
smtp: SMTPConfig

constructor() {
this.token = new TokenConfig()
this.password = new PasswordConfig()
this.search = new SearchConfig()
this.smtp = new SMTPConfig()
}
}

export class DatabaseConfig {
url: string
}

export class TokenConfig {
jwtSigningKey: string
audience: string
Expand All @@ -36,6 +34,14 @@ export class TokenConfig {
refreshTokenLifetime: number
}

export class PasswordConfig {
minLength: number
minLowercase: number
minUppercase: number
minNumbers: number
minSymbols: number
}

export class SearchConfig {
url: string
}
Expand Down
12 changes: 10 additions & 2 deletions idp/src/infra/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
// the Business Source License, use of this software will be governed
// by the GNU Affero General Public License v3.0 only, included in the file
// licenses/AGPL.txt.

import { Request, Response, NextFunction } from 'express'

export enum ErrorCode {
Expand Down Expand Up @@ -124,11 +123,20 @@ export function errorHandler(
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
export function parseValidationError(result: any): ErrorData {
let message: string
let userMessage: string
if (result.errors) {
message = result.errors
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
.map((e: any) => `${e.msg} for ${e.type} ${e.path} in ${e.location}.`)
.join(' ')
userMessage = result.errors
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
.map((e: any) => `${e.msg} for ${e.type} ${e.path}.`)
.join(' ')
}
return newError({ code: ErrorCode.RequestValidationError, message })
return newError({
code: ErrorCode.RequestValidationError,
message,
userMessage,
})
}
26 changes: 25 additions & 1 deletion ui/src/client/idp/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// the Business Source License, use of this software will be governed
// by the GNU Affero General Public License v3.0 only, included in the file
// licenses/AGPL.txt.

import useSWR, { SWRConfiguration } from 'swr'
import { idpFetcher } from '@/client/fetcher'
import { User } from './user'

Expand All @@ -31,6 +31,14 @@ export type ConfirmEmailOptions = {
token: string
}

export type PasswordRequirements = {
minLength: number
minLowercase: number
minUppercase: number
minNumbers: number
minSymbols: number
}

export default class AccountAPI {
static async create(options: CreateOptions) {
return idpFetcher({
Expand Down Expand Up @@ -65,4 +73,20 @@ export default class AccountAPI {
body: JSON.stringify(options),
})
}

static async getPasswordRequirements() {
return idpFetcher({
url: `/accounts/password_requirements`,
method: 'GET',
})
}

static useGetPasswordRequirements(swrOptions?: SWRConfiguration) {
const url = `/accounts/password_requirements`
return useSWR<PasswordRequirements | undefined>(
url,
() => idpFetcher({ url, method: 'GET' }) as Promise<PasswordRequirements>,
swrOptions,
)
}
}
96 changes: 96 additions & 0 deletions ui/src/components/sign-up/password-hints.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import cx from 'classnames'
import { PasswordRequirements } from '@/client/idp/account'
import { IconCheck } from '@/lib/components/icons'

export type PasswordHintsProps = {
value: string
requirements: PasswordRequirements
}

const PasswordHints = ({ value, requirements }: PasswordHintsProps) => {
return (
<div className={cx('flex', 'flex-col')}>
<PasswordRequirement
text={`Length is at least ${requirements.minLength} characters.`}
isFulfilled={hasMinLength(value, requirements.minLength)}
/>
<PasswordRequirement
text={`Contains at least ${requirements.minLowercase} lowercase character.`}
isFulfilled={hasMinLowerCase(value, requirements.minLowercase)}
/>
<PasswordRequirement
text={`Contains at least ${requirements.minUppercase} uppercase character.`}
isFulfilled={hasMinUpperCase(value, requirements.minUppercase)}
/>
<PasswordRequirement
text={`Contains at least ${requirements.minNumbers} number.`}
isFulfilled={hasMinNumbers(value, requirements.minNumbers)}
/>
<PasswordRequirement
text={`Contains at least ${requirements.minSymbols} special character(s) (!#$%).`}
isFulfilled={hasMinSymbols(value, requirements.minSymbols)}
/>
</div>
)
}

function hasMinLength(value: string, minimum: number): boolean {
return value.length >= minimum
}

function hasMinLowerCase(value: string, minimum: number): boolean {
const lowerCaseCount = Array.from(value).filter(
(char) => char === char.toLowerCase() && char !== char.toUpperCase(),
).length
return lowerCaseCount >= minimum
}

function hasMinUpperCase(value: string, minimum: number): boolean {
const upperCaseCount = Array.from(value).filter(
(char) => char === char.toUpperCase() && char !== char.toLowerCase(),
).length
return upperCaseCount >= minimum
}

function hasMinNumbers(value: string, minimum: number): boolean {
const numbersCount = Array.from(value).filter(
(char) => !isNaN(Number(char)),
).length
return numbersCount >= minimum
}

function hasMinSymbols(value: string, minimum: number): boolean {
const symbolsCount = Array.from(value).filter(
(char) => !char.match(/[a-zA-Z0-9\s]/),
).length
return symbolsCount >= minimum
}

export type PasswordRequirementProps = {
text: string
isFulfilled?: boolean
}

export const PasswordRequirement = ({
text,
isFulfilled,
}: PasswordRequirementProps) => {
return (
<div
className={cx(
'flex flex-row',
'gap-0.5',
'items-center',
{ 'text-gray-400': !isFulfilled },
{ 'dark:text-gray-500': !isFulfilled },
{ 'text-green-500': isFulfilled },
{ 'dark:text-green-400': isFulfilled },
)}
>
<IconCheck />
<span>{text}</span>
</div>
)
}

export default PasswordHints
13 changes: 11 additions & 2 deletions ui/src/pages/sign-up-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
// the Business Source License, use of this software will be governed
// by the GNU Affero General Public License v3.0 only, included in the file
// licenses/AGPL.txt.

import { useCallback, useState } from 'react'
import { Link } from 'react-router-dom'
import {
Expand All @@ -32,6 +31,7 @@ import { Helmet } from 'react-helmet-async'
import AccountAPI from '@/client/idp/account'
import Logo from '@/components/common/logo'
import LayoutFull from '@/components/layout/layout-full'
import PasswordHints from '@/components/sign-up/password-hints'

type FormValues = {
fullName: string
Expand All @@ -52,6 +52,7 @@ const SignUpPage = () => {
.oneOf([Yup.ref('password'), undefined], 'Passwords must match')
.required('Confirm your password'),
})
const { data: passwordRequirements } = AccountAPI.useGetPasswordRequirements()

const handleSubmit = useCallback(
async (
Expand Down Expand Up @@ -129,7 +130,7 @@ const SignUpPage = () => {
validateOnBlur={false}
onSubmit={handleSubmit}
>
{({ errors, touched, isSubmitting }) => (
{({ errors, touched, isSubmitting, values }) => (
<Form className={cx('w-full')}>
<div
className={cx(
Expand Down Expand Up @@ -188,6 +189,14 @@ const SignUpPage = () => {
disabled={isSubmitting}
/>
<FormErrorMessage>{errors.password}</FormErrorMessage>
{passwordRequirements ? (
<div className="pt-1">
<PasswordHints
value={values.password}
requirements={passwordRequirements}
/>
</div>
) : null}
</FormControl>
)}
</Field>
Expand Down

0 comments on commit 07308f7

Please sign in to comment.