add flash message

This commit is contained in:
2025-11-30 20:11:26 -03:00
parent b5f3648f44
commit 9a0272af1d
8 changed files with 111 additions and 21 deletions

View File

@@ -52,8 +52,13 @@ def add(
) )
with dyn.transact_writer() as transact: with dyn.transact_writer() as transact:
transact.condition( transact.update(
key=KeyPair(user_id, '0'), key=KeyPair(user_id, '0'),
# Makes the email searchable
update_expr='ADD emails :email',
expr_attr_values={
':email': {email},
},
cond_expr='attribute_exists(sk)', cond_expr='attribute_exists(sk)',
exc_cls=UserNotFoundError, exc_cls=UserNotFoundError,
) )
@@ -75,6 +80,7 @@ def add(
'sk': email, 'sk': email,
'created_at': now_, 'created_at': now_,
}, },
# Prevent duplicate emails
cond_expr='attribute_not_exists(sk)', cond_expr='attribute_not_exists(sk)',
exc_cls=EmailConflictError, exc_cls=EmailConflictError,
) )
@@ -172,7 +178,7 @@ def verify(user_id: str, code: str):
exc_cls=UserNotFoundError, exc_cls=UserNotFoundError,
) )
return JSONResponse(status_code=HTTPStatus.NO_CONTENT) return JSONResponse(status_code=HTTPStatus.OK, body={'email_verified': email})
@router.patch('/<user_id>/emails/primary') @router.patch('/<user_id>/emails/primary')
@@ -213,13 +219,11 @@ def primary(
) )
transact.update( transact.update(
key=KeyPair(user_id, '0'), key=KeyPair(user_id, '0'),
update_expr='DELETE emails :email_set \ update_expr='SET email = :email, \
SET email = :email, \
email_verified = :email_verified, \ email_verified = :email_verified, \
updated_at = :now', updated_at = :now',
expr_attr_values={ expr_attr_values={
':email': new_email, ':email': new_email,
':email_set': {new_email},
':email_verified': email_verified, ':email_verified': email_verified,
':now': now_, ':now': now_,
}, },

View File

@@ -44,14 +44,16 @@ def test_add_email(
) )
assert r['statusCode'] == HTTPStatus.CREATED assert r['statusCode'] == HTTPStatus.CREATED
email_verification = dynamodb_persistence_layer.collection.query( r = dynamodb_persistence_layer.collection.query(
KeyPair( KeyPair(
'15bacf02-1535-4bee-9022-19d106fd7518', '15bacf02-1535-4bee-9022-19d106fd7518',
'EMAIL_VERIFICATION', 'EMAIL_VERIFICATION',
) )
) )
assert 'name' in email_verification['items'][0] items = r['items']
assert email_verification['items'][0]['email'] == 'osergiosiqueira+pytest@gmail.com'
assert len(items) == 2
assert any(x.get('email') == 'osergiosiqueira+pytest@gmail.com' for x in items)
def test_email_as_primary( def test_email_as_primary(
@@ -59,6 +61,7 @@ def test_email_as_primary(
seeds, seeds,
http_api_proxy: HttpApiProxy, http_api_proxy: HttpApiProxy,
lambda_context: LambdaContext, lambda_context: LambdaContext,
dynamodb_persistence_layer: DynamoDBPersistenceLayer,
): ):
r = app.lambda_handler( r = app.lambda_handler(
http_api_proxy( http_api_proxy(
@@ -75,6 +78,12 @@ def test_email_as_primary(
assert r['statusCode'] == HTTPStatus.NO_CONTENT assert r['statusCode'] == HTTPStatus.NO_CONTENT
r = dynamodb_persistence_layer.collection.get_item(
KeyPair('15bacf02-1535-4bee-9022-19d106fd7518', '0')
)
assert r['email'] == 'osergiosiqueira@gmail.com'
assert r['emails'] == {'osergiosiqueira@gmail.com', 'sergio@somosbeta.combr'}
def test_verify_email( def test_verify_email(
app, app,

View File

@@ -1,6 +1,6 @@
// Users // Users
{"id": "213a6682-2c59-4404-9189-12eec0a846d4", "sk": "orgs#f6000f79-6e5c-49a0-952f-3bda330ef278", "name": "Banco do Brasil", "cnpj": "00000000000191"} {"id": "213a6682-2c59-4404-9189-12eec0a846d4", "sk": "orgs#f6000f79-6e5c-49a0-952f-3bda330ef278", "name": "Banco do Brasil", "cnpj": "00000000000191"}
{"id": "15bacf02-1535-4bee-9022-19d106fd7518", "sk": "0", "name": "Sérgio R Siqueira", "email": "sergio@somosbeta.com.br", "cpf": "07879819908"} {"id": "15bacf02-1535-4bee-9022-19d106fd7518", "sk": "0", "name": "Sérgio R Siqueira", "email": "sergio@somosbeta.com.br", "emails": ["osergiosiqueira@gmail.com", "sergio@somosbeta.combr"], "cpf": "07879819908"}
{"id": "15bacf02-1535-4bee-9022-19d106fd7518", "sk": "emails#sergio@somosbeta.com.br", "email_primary": true, "mx_record_exists": true} {"id": "15bacf02-1535-4bee-9022-19d106fd7518", "sk": "emails#sergio@somosbeta.com.br", "email_primary": true, "mx_record_exists": true}
{"id": "15bacf02-1535-4bee-9022-19d106fd7518", "sk": "emails#osergiosiqueira@gmail.com", "email_verified": false, "mx_record_exists": true} {"id": "15bacf02-1535-4bee-9022-19d106fd7518", "sk": "emails#osergiosiqueira@gmail.com", "email_verified": false, "mx_record_exists": true}
{"id": "15bacf02-1535-4bee-9022-19d106fd7518", "sk": "EMAIL_VERIFICATION#0d29c753-55f8-42d2-908b-e4976aafc183", "email": "osergiosiqueira@gmail.com", "name": "Sérgio Rafael de Siqueira"} {"id": "15bacf02-1535-4bee-9022-19d106fd7518", "sk": "EMAIL_VERIFICATION#0d29c753-55f8-42d2-908b-e4976aafc183", "email": "osergiosiqueira@gmail.com", "name": "Sérgio Rafael de Siqueira"}

View File

@@ -3,6 +3,7 @@ import type { Route } from './+types/layout'
import { useToggle } from 'ahooks' import { useToggle } from 'ahooks'
import { MenuIcon } from 'lucide-react' import { MenuIcon } from 'lucide-react'
import { Link, NavLink, Outlet } from 'react-router' import { Link, NavLink, Outlet } from 'react-router'
import { toast } from 'sonner'
import { Toaster } from '@repo/ui/components/ui/sonner' import { Toaster } from '@repo/ui/components/ui/sonner'
import { userContext } from '@repo/auth/context' import { userContext } from '@repo/auth/context'
@@ -23,12 +24,31 @@ import {
SheetTrigger SheetTrigger
} from '@repo/ui/components/ui/sheet' } from '@repo/ui/components/ui/sheet'
import type { User } from '@repo/auth/auth' import type { User } from '@repo/auth/auth'
import { createSessionStorage } from '@repo/auth/session'
import { data } from 'react-router'
import { useEffect } from 'react'
export const middleware: Route.MiddlewareFunction[] = [authMiddleware] export const middleware: Route.MiddlewareFunction[] = [authMiddleware]
export async function loader({ context }: Route.ActionArgs) { export async function loader({ context, request }: Route.ActionArgs) {
const user = context.get(userContext) as User const user = context.get(userContext) as User
return Response.json({ user }) const sessionStorage = createSessionStorage(context.cloudflare.env)
const session = await sessionStorage.getSession(request.headers.get('cookie'))
const flash = {
error: session.get('error'),
success: session.get('success'),
info: session.get('info')
}
return data(
{ user, flash },
{
headers: new Headers({
'Set-Cookie': await sessionStorage.commitSession(session)
})
}
)
} }
const navMain = [ const navMain = [
@@ -47,10 +67,16 @@ const navMain = [
] ]
export default function Component({ export default function Component({
loaderData: { user } loaderData: { user, flash }
}: Route.ComponentProps) { }: Route.ComponentProps) {
const [isOpen, { toggle }] = useToggle() const [isOpen, { toggle }] = useToggle()
useEffect(() => {
if (flash.error) toast.error(flash.error)
if (flash.success) toast.success(flash.success)
if (flash.info) toast.info(flash.info)
}, [flash])
return ( return (
<div className="relative flex flex-col flex-1 min-w-0 h-full"> <div className="relative flex flex-col flex-1 min-w-0 h-full">
<header <header

View File

@@ -42,7 +42,6 @@ import { request as req } from '@repo/util/request'
import { Skeleton } from '@repo/ui/components/skeleton' import { Skeleton } from '@repo/ui/components/skeleton'
import { Button } from '@repo/ui/components/ui/button' import { Button } from '@repo/ui/components/ui/button'
import { useOutletContext } from 'react-router' import { useOutletContext } from 'react-router'
import type { User as AuthUser } from '@repo/auth/auth'
import type { User } from '@repo/ui/routes/users/data' import type { User } from '@repo/ui/routes/users/data'
import { import {
Item, Item,
@@ -59,7 +58,8 @@ import { Primary } from './primary'
const ActionMenuContext = createContext<Email | null>(null) const ActionMenuContext = createContext<Email | null>(null)
export async function loader({ request, context }: Route.LoaderArgs) { export async function loader({ request, context }: Route.LoaderArgs) {
const user = context.get(userContext) as AuthUser const user = context.get(userContext)
const data = req({ const data = req({
url: `/users/${user.sub}/emails`, url: `/users/${user.sub}/emails`,
request, request,

View File

@@ -0,0 +1,33 @@
import type { Route } from './+types/verify'
import { redirect } from 'react-router'
import { userContext } from '@repo/auth/context'
import { createSessionStorage } from '@repo/auth/session'
import { HttpMethod, request as req } from '@repo/util/request'
export async function loader({ params, request, context }: Route.LoaderArgs) {
const { code } = params
const sessionStorage = createSessionStorage(context.cloudflare.env)
const session = await sessionStorage.getSession(request.headers.get('cookie'))
const user = context.get(userContext)
const r = await req({
url: `/users/${user.sub}/emails/${code}/verify`,
method: HttpMethod.POST,
request,
context
})
if (r.ok) {
session.flash('success', 'Seu email foi verificado.')
} else {
session.flash('info', 'O email já está verificado.')
}
return redirect('/settings/emails', {
headers: new Headers({
'Set-Cookie': await sessionStorage.commitSession(session)
})
})
}

View File

@@ -15,7 +15,7 @@ export const authMiddleware = async (
const strategy = authenticator.get<OAuth2Strategy<User>>('oidc') const strategy = authenticator.get<OAuth2Strategy<User>>('oidc')
const session = await sessionStorage.getSession(request.headers.get('cookie')) const session = await sessionStorage.getSession(request.headers.get('cookie'))
const requestId = context.get(requestIdContext) const requestId = context.get(requestIdContext)
let user = session.get('user') as User | null let user = session.get('user')
if (!user) { if (!user) {
console.log('There is no user logged in') console.log('There is no user logged in')
@@ -67,7 +67,11 @@ export const authMiddleware = async (
context.set(userContext, user) context.set(userContext, user)
const response = await next() const response = await next()
const sessionCookie = await sessionStorage.commitSession(session)
response.headers.set('Set-Cookie', sessionCookie) if (!response.headers.has('Set-Cookie')) {
const sessionCookie = await sessionStorage.commitSession(session)
response.headers.set('Set-Cookie', sessionCookie)
}
return response return response
} }

View File

@@ -1,7 +1,22 @@
import { createCookieSessionStorage } from 'react-router' import { createCookieSessionStorage } from 'react-router'
import { type User } from './auth'
export function createSessionStorage(env) { type SessionData = {
const sessionStorage = createCookieSessionStorage({ user: User
returnTo: string
}
type SessionFlashData = {
error: string
success: string
info: string
}
export function createSessionStorage(env: any) {
const cookieSessionStorage = createCookieSessionStorage<
SessionData,
SessionFlashData
>({
cookie: { cookie: {
name: '__session', name: '__session',
httpOnly: true, httpOnly: true,
@@ -12,6 +27,5 @@ export function createSessionStorage(env) {
maxAge: 86400 * 7 // 7 days maxAge: 86400 * 7 // 7 days
} }
}) })
return cookieSessionStorage
return sessionStorage
} }