add forgot login

This commit is contained in:
2025-12-05 10:23:22 -03:00
parent b929c492c0
commit 7c3239d856
14 changed files with 270 additions and 57 deletions

View File

@@ -41,3 +41,13 @@ export default async function handleRequest(
status: responseStatusCode status: responseStatusCode
}) })
} }
// https://reactrouter.com/how-to/suspense#timeouts
export const streamTimeout = 7_000
// https://reactrouter.com/how-to/error-reporting
export const handleError: HandleErrorFunction = (error, { request }) => {
if (!request.signal.aborted) {
console.error(error)
}
}

View File

@@ -8,7 +8,7 @@ import {
export default [ export default [
layout('routes/layout.tsx', [ layout('routes/layout.tsx', [
index('routes/index.tsx'), index('routes/index.tsx'),
route('/reset/:code', 'routes/reset.tsx'), route('/reset/:token', 'routes/reset.tsx'),
route('/forgot', 'routes/forgot.tsx'), route('/forgot', 'routes/forgot.tsx'),
route('/deny', 'routes/deny.tsx'), route('/deny', 'routes/deny.tsx'),
layout('routes/register/layout.tsx', [ layout('routes/register/layout.tsx', [

View File

@@ -1,4 +1,4 @@
import type { Route } from './+types' import type { Route } from './+types/authorize'
import { redirect } from 'react-router' import { redirect } from 'react-router'
import { parse } from 'cookie' import { parse } from 'cookie'
@@ -7,8 +7,8 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const cookies = parse(request.headers.get('Cookie') || '') const cookies = parse(request.headers.get('Cookie') || '')
const url = new URL(request.url) const url = new URL(request.url)
const loginUrl = new URL('/', url.origin) const loginUrl = new URL('/', url.origin)
const issuerUrl = new URL('/authorize', context.cloudflare.env.ISSUER_URL) const authorizeUrl = new URL('/authorize', context.cloudflare.env.ISSUER_URL)
issuerUrl.search = url.search authorizeUrl.search = url.search
loginUrl.search = url.search loginUrl.search = url.search
if (!cookies?.SID) { if (!cookies?.SID) {
@@ -19,7 +19,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
throw redirect(context.cloudflare.env.APP_URL) throw redirect(context.cloudflare.env.APP_URL)
} }
const r = await fetch(issuerUrl.toString(), { const r = await fetch(authorizeUrl.toString(), {
method: 'GET', method: 'GET',
headers: new Headers([ headers: new Headers([
['Content-Type', 'application/json'], ['Content-Type', 'application/json'],

View File

@@ -1,4 +1,4 @@
import type { Route } from './+types' import type { Route } from './+types/deny'
import { LockIcon } from 'lucide-react' import { LockIcon } from 'lucide-react'

View File

@@ -1,9 +1,10 @@
import type { Route } from './+types' import type { Route } from './+types/forgot'
import { isValidCPF } from '@brazilian-utils/brazilian-utils' import { isValidCPF } from '@brazilian-utils/brazilian-utils'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { Link } from 'react-router' import { Link } from 'react-router'
import { MailIcon } from 'lucide-react'
import { z } from 'zod' import { z } from 'zod'
import logo from '@repo/ui/components/logo2.svg' import logo from '@repo/ui/components/logo2.svg'
@@ -17,6 +18,10 @@ import {
FormMessage FormMessage
} from '@repo/ui/components/ui/form' } from '@repo/ui/components/ui/form'
import { Input } from '@repo/ui/components/ui/input' import { Input } from '@repo/ui/components/ui/input'
import { useFetcher } from 'react-router'
import { Spinner } from '@repo/ui/components/ui/spinner'
import { request as req } from '@repo/util/request'
import { useEffect } from 'react'
const schema = z.object({ const schema = z.object({
username: z username: z
@@ -38,14 +43,67 @@ export function meta({}: Route.MetaArgs) {
return [{ title: 'Redefinir senha · EDUSEG®' }] return [{ title: 'Redefinir senha · EDUSEG®' }]
} }
export default function Forgot({}: Route.ComponentProps) { export async function action({ request, context }: Route.ActionArgs) {
const form = useForm({ const url = new URL('/forgot', context.cloudflare.env.ISSUER_URL)
resolver: zodResolver(schema) const body = await request.json()
console.log(url.toString())
const r = await fetch(url.toString(), {
method: 'POST',
headers: new Headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify(body),
signal: request.signal
}) })
const { control, handleSubmit, formState } = form const data = (await r.json()) as any
if (r.ok) {
return { ok: true, ...data }
}
return { ok: false, ...data }
}
export default function Forgot({}: Route.ComponentProps) {
const fetcher = useFetcher()
const form = useForm({ resolver: zodResolver(schema) })
const { control, handleSubmit, formState, setError } = form
const onSubmit = async (data: Schema) => { const onSubmit = async (data: Schema) => {
console.log(data) await fetcher.submit(data, {
method: 'POST',
encType: 'application/json'
})
}
useEffect(() => {
const message = fetcher.data?.message
switch (message) {
case 'User not found':
return setError('username', {
message:
'Não encontramos sua conta. Verifique se está usando o Email ou CPF correto',
type: 'manual'
})
}
}, [fetcher.data, setError])
if (fetcher.data?.ok) {
return (
<div className="flex flex-col text-center items-center gap-6">
<MailIcon className="size-12" />
<div className="space-y-1.5">
<h1 className="text-xl text-gray-10 font-bold">
Verifique seu email
</h1>
<p>
Acabamos de enviar um email com as instruções para{' '}
<span className="font-medium italic">{fetcher.data?.email}</span>
</p>
</div>
</div>
)
} }
return ( return (
@@ -68,7 +126,7 @@ export default function Forgot({}: Route.ComponentProps) {
</div> </div>
<Form {...form}> <Form {...form}>
<form onSubmit={handleSubmit(onSubmit)} className="grid gap-6"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<FormField <FormField
control={control} control={control}
name="username" name="username"
@@ -93,6 +151,7 @@ export default function Forgot({}: Route.ComponentProps) {
className="w-full cursor-pointer" className="w-full cursor-pointer"
disabled={formState.isSubmitting} disabled={formState.isSubmitting}
> >
{formState.isSubmitting && <Spinner />}
Enviar instruções Enviar instruções
</Button> </Button>
</form> </form>

View File

@@ -47,14 +47,11 @@ export function meta({}: Route.MetaArgs) {
} }
export async function action({ request, context }: Route.ActionArgs) { export async function action({ request, context }: Route.ActionArgs) {
const issuerUrl = new URL( const url = new URL('/authentication', context.cloudflare.env.ISSUER_URL)
'/authentication',
context.cloudflare.env.ISSUER_URL
)
const formData = Object.fromEntries(await request.formData()) const formData = Object.fromEntries(await request.formData())
try { try {
const r = await fetch(issuerUrl.toString(), { const r = await fetch(url.toString(), {
method: 'POST', method: 'POST',
headers: new Headers({ 'Content-Type': 'application/json' }), headers: new Headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify(formData) body: JSON.stringify(formData)

View File

@@ -114,15 +114,8 @@ export function Cpf() {
)} )}
/> />
<Button <Button type="submit" className="w-full cursor-pointer">
type="submit" {formState.isSubmitting && <Spinner />}
className="w-full cursor-pointer relative overflow-hidden"
>
{formState.isSubmitting && (
<div className="absolute bg-lime-500 inset-0 flex items-center justify-center">
<Spinner />
</div>
)}
Continuar Continuar
</Button> </Button>
</fieldset> </fieldset>

View File

@@ -1,4 +1,4 @@
import type { Route } from '../+types' import type { Route } from './+types/index'
import { PatternFormat } from 'react-number-format' import { PatternFormat } from 'react-number-format'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
@@ -34,10 +34,10 @@ export function meta({}: Route.MetaArgs) {
} }
export async function action({ request, context }: Route.ActionArgs) { export async function action({ request, context }: Route.ActionArgs) {
const issuerUrl = new URL('/register', context.cloudflare.env.ISSUER_URL) const url = new URL('/register', context.cloudflare.env.ISSUER_URL)
const body = await request.json() const body = await request.json()
const r = await fetch(issuerUrl.toString(), { const r = await fetch(url.toString(), {
method: 'POST', method: 'POST',
headers: new Headers({ 'Content-Type': 'application/json' }), headers: new Headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify(body), body: JSON.stringify(body),
@@ -62,7 +62,7 @@ export default function Signup({}: Route.ComponentProps) {
const onSubmit = async (data: Schema) => { const onSubmit = async (data: Schema) => {
await fetcher.submit(JSON.stringify({ ...user, ...data }), { await fetcher.submit(JSON.stringify({ ...user, ...data }), {
method: 'post', method: 'POST',
encType: 'application/json' encType: 'application/json'
}) })
} }
@@ -81,7 +81,7 @@ export default function Signup({}: Route.ComponentProps) {
<RegisterContext value={{ user, setUser }}> <RegisterContext value={{ user, setUser }}>
{user ? ( {user ? (
<Form {...form}> <Form {...form}>
<form onSubmit={handleSubmit(onSubmit)} className="grid gap-6"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{user?.never_logged && ( {user?.never_logged && (
<Alert> <Alert>
<CheckCircle2Icon /> <CheckCircle2Icon />
@@ -197,14 +197,10 @@ export default function Signup({}: Route.ComponentProps) {
<Button <Button
type="submit" type="submit"
className="w-full cursor-pointer relative overflow-hidden" className="w-full cursor-pointer"
disabled={formState.isSubmitting} disabled={formState.isSubmitting}
> >
{formState.isSubmitting && ( {formState.isSubmitting && <Spinner />}
<div className="absolute bg-lime-500 inset-0 flex items-center justify-center">
<Spinner />
</div>
)}
Criar conta Criar conta
</Button> </Button>
</form> </form>

View File

@@ -1,4 +1,4 @@
import type { Route } from './+types/index' import type { Route } from './+types/layout'
import { Outlet, Link } from 'react-router' import { Outlet, Link } from 'react-router'

View File

@@ -0,0 +1,157 @@
import type { Route } from './+types/reset'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useFetcher, redirect } from 'react-router'
import { z } from 'zod'
import logo from '@repo/ui/components/logo2.svg'
import { Input } from '@repo/ui/components/ui/input'
import { Button } from '@repo/ui/components/ui/button'
import { Checkbox } from '@repo/ui/components/ui/checkbox'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@repo/ui/components/ui/form'
import { Label } from '@repo/ui/components/ui/label'
import { Spinner } from '@repo/ui/components/ui/spinner'
import { request as req } from '@repo/util/request'
export const formSchema = z
.object({
password: z
.string()
.nonempty('Digite sua senha')
.min(6, 'Deve ter no mínimo 6 caracteres'),
confirm_password: z.string()
})
.refine((data) => data.password === data.confirm_password, {
message: 'As senhas não coincidem',
path: ['confirm_password']
})
export type Schema = z.infer<typeof formSchema>
export function meta({}: Route.MetaArgs) {
return [{ title: 'Define sua nova senha · EDUSEG®' }]
}
export async function action({ params, request, context }: Route.ActionArgs) {
const { token } = params
const url = new URL(`/reset/${token}`, context.cloudflare.env.ISSUER_URL)
const body = await request.json()
console.log(url.toString())
// const r = await fetch(issuerUrl.toString(), {
// method: 'POST',
// headers: new Headers({ 'Content-Type': 'application/json' }),
// body: JSON.stringify(body),
// signal: request.signal
// })
// if (r.ok) {
// throw redirect('/authorize', { headers: r.headers })
// }
// return { ok: false, error: await r.json() }
await new Promise((r) => setTimeout(r, 2000))
return { ok: true }
}
export default function Route({}: Route.ComponentProps) {
const fetcher = useFetcher()
const [show, setShow] = useState(false)
const form = useForm({ resolver: zodResolver(formSchema) })
const { handleSubmit, control, formState } = form
const onSubmit = async ({ password }: Schema) => {
await fetcher.submit(JSON.stringify({ new_password: password }), {
method: 'POST',
encType: 'application/json'
})
}
return (
<div className="space-y-6">
<div className="flex justify-center">
<div className="border border-white/15 bg-white/5 px-2.5 py-3 rounded-xl">
<img src={logo} alt="EDUSEG®" className="block size-12" />
</div>
</div>
<div className="text-center space-y-1.5">
<h1 className="text-2xl font-semibold font-display text-balance">
Defina sua nova senha
</h1>
<p className="text-white/50 text-sm">
Defina uma nova senha para manter sua conta sempre segura.
</p>
</div>
<Form {...form}>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={control}
name="password"
defaultValue=""
render={({ field }) => (
<FormItem>
<FormLabel>Senha</FormLabel>
<FormControl>
<Input
type={show ? 'text' : 'password'}
autoComplete="false"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="confirm_password"
defaultValue=""
render={({ field }) => (
<FormItem>
<FormLabel>Confirmar senha</FormLabel>
<FormControl>
<Input
type={show ? 'text' : 'password'}
autoComplete="false"
{...field}
/>
</FormControl>
<div className="flex items-center gap-3">
<Checkbox
id="showPassword"
onClick={() => setShow((x) => !x)}
tabIndex={-1}
/>
<Label htmlFor="showPassword">Mostrar senha</Label>
</div>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full cursor-pointer"
disabled={formState.isSubmitting}
>
{formState.isSubmitting && <Spinner />}
Definir senha
</Button>
</form>
</Form>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import type { Route } from './+types' import type { Route } from './+types/upstream'
export const loader = proxy export const loader = proxy
export const action = proxy export const action = proxy

View File

@@ -3,10 +3,10 @@ from http import HTTPStatus
from typing import Annotated from typing import Annotated
from uuid import uuid4 from uuid import uuid4
from aws_lambda_powertools.event_handler import content_types
from aws_lambda_powertools.event_handler.api_gateway import Response, Router from aws_lambda_powertools.event_handler.api_gateway import Response, Router
from aws_lambda_powertools.event_handler.exceptions import NotFoundError from aws_lambda_powertools.event_handler.exceptions import NotFoundError
from aws_lambda_powertools.event_handler.openapi.params import Body from aws_lambda_powertools.event_handler.openapi.params import Body
from aws_lambda_powertools.utilities.data_masking import DataMasking
from layercake.dateutils import now, ttl from layercake.dateutils import now, ttl
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey
from layercake.extra_types import CpfStr from layercake.extra_types import CpfStr
@@ -18,22 +18,17 @@ from config import USER_TABLE
router = Router() router = Router()
dyn = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client) dyn = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
data_masker = DataMasking()
masking_rules = {
'email': {'regex_pattern': '(.)(.*)(..)(@.*)', 'mask_format': r'\1****\3\4'},
}
class UserNotFoundError(NotFoundError): ... class UserNotFoundError(NotFoundError): ...
@router.post('/forgot') @router.post('/forgot', compress=True)
def forgot(username: Annotated[EmailStr | CpfStr, Body(embed=True)]): def forgot(username: Annotated[EmailStr | CpfStr, Body(embed=True)]):
now_ = now() now_ = now()
user = _get_user(username) user = _get_user(username)
reset_ttl = ttl(start_dt=now_, hours=3) reset_ttl = ttl(start_dt=now_, hours=3)
code = uuid4() code = str(uuid4())
with dyn.transact_writer() as transact: with dyn.transact_writer() as transact:
transact.update( transact.update(
@@ -63,6 +58,7 @@ def forgot(username: Annotated[EmailStr | CpfStr, Body(embed=True)]):
'id': 'PASSWORD_RESET', 'id': 'PASSWORD_RESET',
'sk': f'CODE#{code}', 'sk': f'CODE#{code}',
'name': user.name, 'name': user.name,
'email': user.email,
'user_id': user.id, 'user_id': user.id,
'ttl': reset_ttl, 'ttl': reset_ttl,
'created_at': now_, 'created_at': now_,
@@ -70,13 +66,11 @@ def forgot(username: Annotated[EmailStr | CpfStr, Body(embed=True)]):
) )
return Response( return Response(
content_type=content_types.APPLICATION_JSON,
status_code=HTTPStatus.CREATED, status_code=HTTPStatus.CREATED,
body=data_masker.erase( body={
{ 'email': mask_email(user.email),
'email': user.email,
}, },
masking_rules=masking_rules,
),
) )
@@ -114,3 +108,9 @@ def _get_user(username: str) -> User:
return User( return User(
**pick(('id', 'name', 'email'), user), **pick(('id', 'name', 'email'), user),
) )
def mask_email(email):
username, domain = email.split('@')
username = username[0] + '*' * (len(username) - 3) + username[-2:]
return f'{username}@{domain}'

View File

@@ -7,7 +7,6 @@ from aws_lambda_powertools.event_handler import content_types
from aws_lambda_powertools.event_handler.api_gateway import Response, Router from aws_lambda_powertools.event_handler.api_gateway import Response, Router
from aws_lambda_powertools.event_handler.exceptions import ServiceError from aws_lambda_powertools.event_handler.exceptions import ServiceError
from aws_lambda_powertools.event_handler.openapi.params import Body from aws_lambda_powertools.event_handler.openapi.params import Body
from aws_lambda_powertools.shared.cookies import Cookie
from layercake.dateutils import now, ttl from layercake.dateutils import now, ttl
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
from layercake.extra_types import CpfStr, NameStr from layercake.extra_types import CpfStr, NameStr

View File

@@ -1,3 +1,4 @@
import json
from http import HTTPMethod from http import HTTPMethod
from layercake.dynamodb import DynamoDBPersistenceLayer, PartitionKey from layercake.dynamodb import DynamoDBPersistenceLayer, PartitionKey
@@ -20,7 +21,8 @@ def test_forgot(
), ),
lambda_context, lambda_context,
) )
assert 's****io@somosbeta.com.br' == r['body']['email'] body = json.loads(r['body'])
assert 's***io@somosbeta.com.br' == body['email']
app.lambda_handler( app.lambda_handler(
http_api_proxy( http_api_proxy(
@@ -34,4 +36,4 @@ def test_forgot(
forgot = dynamodb_persistence_layer.collection.query( forgot = dynamodb_persistence_layer.collection.query(
PartitionKey('PASSWORD_RESET'), PartitionKey('PASSWORD_RESET'),
) )
assert len(forgot['items']) == 3 assert len(forgot['items']) == 5