diff --git a/api.saladeaula.digital/app/routes/users/emails.py b/api.saladeaula.digital/app/routes/users/emails.py index bc329ee..4df6639 100644 --- a/api.saladeaula.digital/app/routes/users/emails.py +++ b/api.saladeaula.digital/app/routes/users/emails.py @@ -52,8 +52,13 @@ def add( ) with dyn.transact_writer() as transact: - transact.condition( + transact.update( key=KeyPair(user_id, '0'), + # Makes the email searchable + update_expr='ADD emails :email', + expr_attr_values={ + ':email': {email}, + }, cond_expr='attribute_exists(sk)', exc_cls=UserNotFoundError, ) @@ -75,6 +80,7 @@ def add( 'sk': email, 'created_at': now_, }, + # Prevent duplicate emails cond_expr='attribute_not_exists(sk)', exc_cls=EmailConflictError, ) @@ -172,7 +178,7 @@ def verify(user_id: str, code: str): exc_cls=UserNotFoundError, ) - return JSONResponse(status_code=HTTPStatus.NO_CONTENT) + return JSONResponse(status_code=HTTPStatus.OK, body={'email_verified': email}) @router.patch('//emails/primary') @@ -213,13 +219,11 @@ def primary( ) transact.update( key=KeyPair(user_id, '0'), - update_expr='DELETE emails :email_set \ - SET email = :email, \ + update_expr='SET email = :email, \ email_verified = :email_verified, \ updated_at = :now', expr_attr_values={ ':email': new_email, - ':email_set': {new_email}, ':email_verified': email_verified, ':now': now_, }, diff --git a/api.saladeaula.digital/tests/routes/users/test_emails.py b/api.saladeaula.digital/tests/routes/users/test_emails.py index e9e5c64..b18f289 100644 --- a/api.saladeaula.digital/tests/routes/users/test_emails.py +++ b/api.saladeaula.digital/tests/routes/users/test_emails.py @@ -44,14 +44,16 @@ def test_add_email( ) assert r['statusCode'] == HTTPStatus.CREATED - email_verification = dynamodb_persistence_layer.collection.query( + r = dynamodb_persistence_layer.collection.query( KeyPair( '15bacf02-1535-4bee-9022-19d106fd7518', 'EMAIL_VERIFICATION', ) ) - assert 'name' in email_verification['items'][0] - assert email_verification['items'][0]['email'] == 'osergiosiqueira+pytest@gmail.com' + items = r['items'] + + assert len(items) == 2 + assert any(x.get('email') == 'osergiosiqueira+pytest@gmail.com' for x in items) def test_email_as_primary( @@ -59,6 +61,7 @@ def test_email_as_primary( seeds, http_api_proxy: HttpApiProxy, lambda_context: LambdaContext, + dynamodb_persistence_layer: DynamoDBPersistenceLayer, ): r = app.lambda_handler( http_api_proxy( @@ -75,6 +78,12 @@ def test_email_as_primary( 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( app, diff --git a/api.saladeaula.digital/tests/seeds.jsonl b/api.saladeaula.digital/tests/seeds.jsonl index c6fb1a5..45194fa 100644 --- a/api.saladeaula.digital/tests/seeds.jsonl +++ b/api.saladeaula.digital/tests/seeds.jsonl @@ -1,6 +1,6 @@ // Users {"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#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"} diff --git a/apps/saladeaula.digital/app/routes/layout.tsx b/apps/saladeaula.digital/app/routes/layout.tsx index e1d7da6..5320a82 100644 --- a/apps/saladeaula.digital/app/routes/layout.tsx +++ b/apps/saladeaula.digital/app/routes/layout.tsx @@ -3,6 +3,7 @@ import type { Route } from './+types/layout' import { useToggle } from 'ahooks' import { MenuIcon } from 'lucide-react' import { Link, NavLink, Outlet } from 'react-router' +import { toast } from 'sonner' import { Toaster } from '@repo/ui/components/ui/sonner' import { userContext } from '@repo/auth/context' @@ -23,12 +24,31 @@ import { SheetTrigger } from '@repo/ui/components/ui/sheet' 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 async function loader({ context }: Route.ActionArgs) { +export async function loader({ context, request }: Route.ActionArgs) { 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 = [ @@ -47,10 +67,16 @@ const navMain = [ ] export default function Component({ - loaderData: { user } + loaderData: { user, flash } }: Route.ComponentProps) { 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 (
(null) export async function loader({ request, context }: Route.LoaderArgs) { - const user = context.get(userContext) as AuthUser + const user = context.get(userContext) + const data = req({ url: `/users/${user.sub}/emails`, request, diff --git a/apps/saladeaula.digital/app/routes/settings/emails/verify.tsx b/apps/saladeaula.digital/app/routes/settings/emails/verify.tsx new file mode 100644 index 0000000..dc86775 --- /dev/null +++ b/apps/saladeaula.digital/app/routes/settings/emails/verify.tsx @@ -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) + }) + }) +} diff --git a/packages/auth/src/middleware/auth.ts b/packages/auth/src/middleware/auth.ts index 93d3868..519f25b 100644 --- a/packages/auth/src/middleware/auth.ts +++ b/packages/auth/src/middleware/auth.ts @@ -15,7 +15,7 @@ export const authMiddleware = async ( const strategy = authenticator.get>('oidc') const session = await sessionStorage.getSession(request.headers.get('cookie')) const requestId = context.get(requestIdContext) - let user = session.get('user') as User | null + let user = session.get('user') if (!user) { console.log('There is no user logged in') @@ -67,7 +67,11 @@ export const authMiddleware = async ( context.set(userContext, user) 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 } diff --git a/packages/auth/src/session.ts b/packages/auth/src/session.ts index 735f2cc..2dfc3ad 100644 --- a/packages/auth/src/session.ts +++ b/packages/auth/src/session.ts @@ -1,7 +1,22 @@ import { createCookieSessionStorage } from 'react-router' +import { type User } from './auth' -export function createSessionStorage(env) { - const sessionStorage = createCookieSessionStorage({ +type SessionData = { + user: User + returnTo: string +} + +type SessionFlashData = { + error: string + success: string + info: string +} + +export function createSessionStorage(env: any) { + const cookieSessionStorage = createCookieSessionStorage< + SessionData, + SessionFlashData + >({ cookie: { name: '__session', httpOnly: true, @@ -12,6 +27,5 @@ export function createSessionStorage(env) { maxAge: 86400 * 7 // 7 days } }) - - return sessionStorage + return cookieSessionStorage }