add notification
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
from typing import Annotated
|
||||
|
||||
from aws_lambda_powertools import Logger
|
||||
from aws_lambda_powertools.event_handler.api_gateway import Router
|
||||
from aws_lambda_powertools.event_handler.openapi.params import Query
|
||||
from layercake.dynamodb import DynamoDBPersistenceLayer, PartitionKey
|
||||
|
||||
from boto3clients import dynamodb_client
|
||||
@@ -11,9 +14,7 @@ dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
|
||||
|
||||
|
||||
@router.get('/<org_id>/enrollments/scheduled')
|
||||
def scheduled(org_id: str):
|
||||
start_key = router.current_event.get_query_string_value('start_key', None)
|
||||
|
||||
def scheduled(org_id: str, start_key: Annotated[str | None, Query] = None):
|
||||
return dyn.collection.query(
|
||||
# Post-migration: rename `scheduled_items` to `SCHEDULED#ORG#{org_id}`
|
||||
key=PartitionKey(f'scheduled_items#{org_id}'),
|
||||
|
||||
@@ -8,7 +8,7 @@ from aws_lambda_powertools.event_handler.exceptions import (
|
||||
ServiceError,
|
||||
)
|
||||
from aws_lambda_powertools.event_handler.openapi.params import Body
|
||||
from layercake.dateutils import now
|
||||
from layercake.dateutils import now, ttl
|
||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey
|
||||
from layercake.extra_types import CnpjStr, CpfStr, NameStr
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
@@ -126,9 +126,26 @@ def _create_user(user: User, org: Org) -> bool:
|
||||
'sk': f'emails#{user.email}',
|
||||
'email_verified': email_verified,
|
||||
'email_primary': True,
|
||||
'mx_record_exists': email_verified,
|
||||
'created_at': now_,
|
||||
}
|
||||
)
|
||||
|
||||
if not email_verified:
|
||||
transact.put(
|
||||
item={
|
||||
'id': user_id,
|
||||
'sk': f'EMAIL_VERIFICATION#{uuid4()}',
|
||||
'fresh_user': True,
|
||||
'name': user.name,
|
||||
'email': user.email,
|
||||
'email_primary': True,
|
||||
'org_name': org.name,
|
||||
'ttl': ttl(start_dt=now_, days=30),
|
||||
'created_at': now_,
|
||||
}
|
||||
)
|
||||
|
||||
transact.put(
|
||||
item={
|
||||
# Post-migration: rename `cpf` to `CPF`
|
||||
|
||||
@@ -9,6 +9,7 @@ from aws_lambda_powertools.event_handler.exceptions import (
|
||||
from aws_lambda_powertools.event_handler.openapi.params import Body, Path, Query
|
||||
from layercake.dateutils import now, ttl
|
||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey
|
||||
from layercake.funcs import pick
|
||||
from pydantic import EmailStr
|
||||
from typing_extensions import Annotated
|
||||
|
||||
@@ -29,6 +30,9 @@ def get_emails(user_id: str, start_key: Annotated[str | None, Query] = None):
|
||||
)
|
||||
|
||||
|
||||
class UserNotFoundError(NotFoundError): ...
|
||||
|
||||
|
||||
class EmailConflictError(ServiceError):
|
||||
def __init__(self, msg: str | dict):
|
||||
super().__init__(HTTPStatus.CONFLICT, msg)
|
||||
@@ -46,13 +50,19 @@ def add(
|
||||
)
|
||||
|
||||
with dyn.transact_writer() as transact:
|
||||
transact.condition(
|
||||
key=KeyPair(user_id, '0'),
|
||||
cond_expr='attribute_exists(sk)',
|
||||
exc_cls=UserNotFoundError,
|
||||
)
|
||||
transact.put(
|
||||
item={
|
||||
'id': user_id,
|
||||
# Post-migration (users): rename `emails` to `EMAIL`
|
||||
'sk': f'emails#{email}',
|
||||
'email_verified': False,
|
||||
'email_primary': True,
|
||||
'mx_record_exists': False,
|
||||
'email_primary': False,
|
||||
'created_at': now_,
|
||||
}
|
||||
)
|
||||
@@ -82,10 +92,16 @@ def add(
|
||||
|
||||
|
||||
@router.post('/<user_id>/emails/<email>/request-verification')
|
||||
def request_verification(user_id: str, email: Annotated[EmailStr, Path]):
|
||||
def request_verification(
|
||||
user_id: str,
|
||||
email: Annotated[EmailStr, Path],
|
||||
):
|
||||
now_ = now()
|
||||
name = dyn.collection.get_item(
|
||||
KeyPair(user_id, SortKey('0', path_spec='name')),
|
||||
KeyPair(
|
||||
pk=user_id,
|
||||
sk=SortKey('0', path_spec='name'),
|
||||
),
|
||||
raise_on_error=False,
|
||||
)
|
||||
dyn.put_item(
|
||||
@@ -107,13 +123,14 @@ class EmailVerificationNotFoundError(NotFoundError): ...
|
||||
|
||||
@router.post('/<user_id>/emails/<hash>/verify')
|
||||
def verify(user_id: str, hash: str):
|
||||
email = dyn.collection.get_item(
|
||||
verification = dyn.collection.get_item(
|
||||
KeyPair(
|
||||
pk=user_id,
|
||||
sk=SortKey(f'EMAIL_VERIFICATION#{hash}', path_spec='email'),
|
||||
sk=f'EMAIL_VERIFICATION#{hash}',
|
||||
),
|
||||
exc_cls=EmailVerificationNotFoundError,
|
||||
)
|
||||
email, primary = pick(('email', 'email_primary'), verification, default=False)
|
||||
|
||||
with dyn.transact_writer() as transact:
|
||||
transact.delete(
|
||||
@@ -129,6 +146,20 @@ def verify(user_id: str, hash: str):
|
||||
},
|
||||
)
|
||||
|
||||
if primary:
|
||||
transact.update(
|
||||
key=KeyPair(user_id, '0'),
|
||||
update_expr='SET email_verified = :true, \
|
||||
updated_at = :now',
|
||||
expr_attr_values={
|
||||
':email': email,
|
||||
':true': True,
|
||||
':now': now(),
|
||||
},
|
||||
cond_expr='attribute_exists(sk)',
|
||||
exc_cls=UserNotFoundError,
|
||||
)
|
||||
|
||||
return JSONResponse(status_code=HTTPStatus.NO_CONTENT)
|
||||
|
||||
|
||||
@@ -140,7 +171,7 @@ def primary(
|
||||
email_verified: Annotated[bool, Body(embed=True)],
|
||||
):
|
||||
now_ = now()
|
||||
expr = 'SET email_primary = :email_primary, updated_at = :updated_at'
|
||||
expr = 'SET email_primary = :email_primary, updated_at = :now'
|
||||
|
||||
with dyn.transact_writer() as transact:
|
||||
# Set the old email as non-primary
|
||||
@@ -150,7 +181,7 @@ def primary(
|
||||
update_expr=expr,
|
||||
expr_attr_values={
|
||||
':email_primary': False,
|
||||
':updated_at': now_,
|
||||
':now': now_,
|
||||
},
|
||||
cond_expr='attribute_exists(sk)',
|
||||
)
|
||||
@@ -161,7 +192,7 @@ def primary(
|
||||
update_expr=expr,
|
||||
expr_attr_values={
|
||||
':email_primary': True,
|
||||
':updated_at': now_,
|
||||
':now': now_,
|
||||
},
|
||||
cond_expr='attribute_exists(sk)',
|
||||
)
|
||||
@@ -170,13 +201,15 @@ def primary(
|
||||
update_expr='DELETE emails :email_set \
|
||||
SET email = :email, \
|
||||
email_verified = :email_verified, \
|
||||
updated_at = :updated_at',
|
||||
updated_at = :now',
|
||||
expr_attr_values={
|
||||
':email': new_email,
|
||||
':email_set': {new_email},
|
||||
':email_verified': email_verified,
|
||||
':updated_at': now_,
|
||||
':now': now_,
|
||||
},
|
||||
cond_expr='attribute_exists(sk)',
|
||||
exc_cls=UserNotFoundError,
|
||||
)
|
||||
|
||||
return JSONResponse(status_code=HTTPStatus.NO_CONTENT)
|
||||
@@ -204,10 +237,14 @@ def remove(
|
||||
)
|
||||
transact.update(
|
||||
key=KeyPair(user_id, '0'),
|
||||
update_expr='DELETE emails :email',
|
||||
update_expr='DELETE emails :email \
|
||||
SET updated_at = :now',
|
||||
expr_attr_values={
|
||||
':email': {email},
|
||||
':now': now(),
|
||||
},
|
||||
cond_expr='attribute_exists(sk)',
|
||||
exc_cls=UserNotFoundError,
|
||||
)
|
||||
|
||||
return JSONResponse(status_code=HTTPStatus.NO_CONTENT)
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
import {
|
||||
BookCopyIcon,
|
||||
CalendarClockIcon,
|
||||
DollarSign,
|
||||
DollarSignIcon,
|
||||
FileBadgeIcon,
|
||||
GraduationCap,
|
||||
LayoutDashboard,
|
||||
LayoutDashboardIcon,
|
||||
ShieldUserIcon,
|
||||
UploadIcon,
|
||||
UsersIcon
|
||||
@@ -26,12 +26,12 @@ const data = {
|
||||
{
|
||||
title: 'Visão geral',
|
||||
url: '/main',
|
||||
icon: LayoutDashboard
|
||||
icon: LayoutDashboardIcon
|
||||
},
|
||||
{
|
||||
title: 'Histórico de compras',
|
||||
url: '/orders',
|
||||
icon: DollarSign
|
||||
icon: DollarSignIcon
|
||||
}
|
||||
],
|
||||
navUser: [
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { BellIcon } from 'lucide-react'
|
||||
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from '@repo/ui/components/ui/popover'
|
||||
import { Button } from '@repo/ui/components/ui/button'
|
||||
|
||||
export function Notification() {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
className="cursor-pointer text-muted-foreground"
|
||||
>
|
||||
<BellIcon className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-80">
|
||||
<>Notificações</>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger
|
||||
} from '@repo/ui/components/ui/alert-dialog'
|
||||
|
||||
import { Button } from '@repo/ui/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -58,24 +57,24 @@ export const columns: ColumnDef<Enrollment>[] = [
|
||||
}
|
||||
]
|
||||
|
||||
async function getEnrollment(id: string) {
|
||||
const r = await fetch(`/~/api/enrollments/${id}`, {
|
||||
function ActionMenu({ row }: { row: any }) {
|
||||
const [open, { set: setOpen }] = useToggle(false)
|
||||
const cert = row.original?.cert
|
||||
const { data, loading, runAsync, refresh } = useRequest(
|
||||
async () => {
|
||||
const r = await fetch(`/~/api/enrollments/${row.id}`, {
|
||||
method: 'GET'
|
||||
})
|
||||
await new Promise((r) => setTimeout(r, 150))
|
||||
|
||||
return (await r.json()) as {
|
||||
cancel_policy?: any
|
||||
lock?: { hash: string }
|
||||
}
|
||||
}
|
||||
|
||||
function ActionMenu({ row }: { row: any }) {
|
||||
const [open, { set: setOpen }] = useToggle(false)
|
||||
const cert = row.original?.cert
|
||||
const { data, loading, run, refresh } = useRequest(getEnrollment, {
|
||||
},
|
||||
{
|
||||
manual: true
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const onSuccess = () => {
|
||||
refresh()
|
||||
@@ -90,7 +89,7 @@ function ActionMenu({ row }: { row: any }) {
|
||||
onOpenChange={(open) => {
|
||||
setOpen(open)
|
||||
if (data) return
|
||||
run(row.id)
|
||||
runAsync()
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -140,22 +139,27 @@ type ItemProps = ComponentProps<typeof DropdownMenuItem> & {
|
||||
}
|
||||
|
||||
function DownloadItem({ id, onSuccess, ...props }: ItemProps) {
|
||||
const [loading, { set }] = useToggle(false)
|
||||
const { runAsync, loading } = useRequest(
|
||||
async () => {
|
||||
return await fetch(`/~/api/enrollments/${id}/download`)
|
||||
},
|
||||
{
|
||||
manual: true
|
||||
}
|
||||
)
|
||||
|
||||
const download = async (e: Event) => {
|
||||
e.preventDefault()
|
||||
set(true)
|
||||
const r = await fetch(`/~/api/enrollments/${id}/download`, {
|
||||
method: 'GET'
|
||||
})
|
||||
|
||||
if (r.ok) {
|
||||
const { presigned_url } = (await r.json()) as { presigned_url: string }
|
||||
window.open(presigned_url, '_blank')
|
||||
|
||||
set(false)
|
||||
onSuccess?.()
|
||||
try {
|
||||
const r = await runAsync()
|
||||
const { presigned_url } = (await r.json()) as {
|
||||
presigned_url: string
|
||||
}
|
||||
|
||||
window.open(presigned_url, '_blank')
|
||||
onSuccess?.()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -299,7 +303,7 @@ function CancelItem({
|
||||
<AlertDialogDescription>
|
||||
Esta ação não pode ser desfeita. Isso{' '}
|
||||
<span className="font-bold">
|
||||
cancelar permanentemente a matrícula
|
||||
cancela permanentemente a matrícula
|
||||
</span>{' '}
|
||||
deste colaborador.
|
||||
</AlertDialogDescription>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Toaster } from '@repo/ui/components/ui/sonner'
|
||||
import { request as req } from '@repo/util/request'
|
||||
|
||||
import { AppSidebar } from '@/components/app-sidebar'
|
||||
import { Notification } from '@/components/notification'
|
||||
|
||||
export const middleware: Route.MiddlewareFunction[] = [authMiddleware]
|
||||
|
||||
@@ -78,6 +79,7 @@ export default function Route({ loaderData }: Route.ComponentProps) {
|
||||
<ThemedImage className="max-md:hidden" />
|
||||
|
||||
<div className="ml-auto flex gap-2.5 items-center">
|
||||
<Notification />
|
||||
<ModeToggle />
|
||||
<NavUser user={user} />
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,8 @@ export default [
|
||||
]),
|
||||
route('konviva', 'routes/konviva.ts'),
|
||||
route('player/:id', 'routes/player.tsx'),
|
||||
route('proxy/*', 'routes/proxy.tsx')
|
||||
route('proxy/*', 'routes/proxy.tsx'),
|
||||
route('api/*', 'routes/api.ts')
|
||||
]),
|
||||
route('logout', 'routes/auth/logout.ts'),
|
||||
route('login', 'routes/auth/login.ts')
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import type { Route } from './+types'
|
||||
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle
|
||||
} from '@repo/ui/components/ui/empty'
|
||||
import Fuse from 'fuse.js'
|
||||
import {
|
||||
BanIcon,
|
||||
@@ -15,6 +8,8 @@ import {
|
||||
CircleOffIcon,
|
||||
CirclePlusIcon,
|
||||
CircleXIcon,
|
||||
EllipsisIcon,
|
||||
FileBadgeIcon,
|
||||
TimerIcon,
|
||||
type LucideIcon
|
||||
} from 'lucide-react'
|
||||
@@ -27,12 +22,28 @@ import { userContext } from '@repo/auth/context'
|
||||
import { FacetedFilter } from '@repo/ui/components/faceted-filter'
|
||||
import { SearchForm } from '@repo/ui/components/search-form'
|
||||
import { Skeleton } from '@repo/ui/components/skeleton'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@repo/ui/components/ui/dropdown-menu'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle
|
||||
} from '@repo/ui/components/ui/empty'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
CardTitle,
|
||||
CardAction
|
||||
} from '@repo/ui/components/ui/card'
|
||||
import { Kbd } from '@repo/ui/components/ui/kbd'
|
||||
import { Progress } from '@repo/ui/components/ui/progress'
|
||||
@@ -40,6 +51,7 @@ import { createSearch } from '@repo/util/meili'
|
||||
|
||||
import placeholder from '@/assets/placeholder.webp'
|
||||
import { Container } from '@/components/container'
|
||||
import { Button } from '@repo/ui/components/ui/button'
|
||||
|
||||
type Course = {
|
||||
name: string
|
||||
@@ -200,8 +212,10 @@ function Enrollment({ id, course, progress }: Enrollment) {
|
||||
<Card className="overflow-hidden relative h-96">
|
||||
<CardHeader className="z-1 relative">
|
||||
<CardTitle className="text-xl/6">{course.name}</CardTitle>
|
||||
<CardAction>
|
||||
<ActionMenu />
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="z-1"></CardContent>
|
||||
<CardFooter className="absolute z-1 bottom-6 w-full flex gap-1.5">
|
||||
<Progress value={progress} />
|
||||
<span className="text-xs">{progress}%</span>
|
||||
@@ -216,6 +230,23 @@ function Enrollment({ id, course, progress }: Enrollment) {
|
||||
)
|
||||
}
|
||||
|
||||
function ActionMenu() {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon-sm" className="cursor-pointer">
|
||||
<EllipsisIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem disabled={true}>
|
||||
<FileBadgeIcon /> Baixar certificado
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
const statuses: Record<
|
||||
string,
|
||||
{ icon: LucideIcon; color?: string; label: string }
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { Route } from './+types'
|
||||
import type { Route } from './+types/layout'
|
||||
|
||||
import { useToggle } from 'ahooks'
|
||||
import { MenuIcon } from 'lucide-react'
|
||||
import { Link, NavLink, Outlet } from 'react-router'
|
||||
import { Toaster } from '@repo/ui/components/ui/sonner'
|
||||
|
||||
import { userContext } from '@repo/auth/context'
|
||||
import { authMiddleware } from '@repo/auth/middleware/auth'
|
||||
|
||||
import { ModeToggle, ThemedImage } from '@repo/ui/components/dark-mode'
|
||||
import { NavUser } from '@repo/ui/components/nav-user'
|
||||
import { Button } from '@repo/ui/components/ui/button'
|
||||
@@ -22,11 +22,12 @@ import {
|
||||
SheetTitle,
|
||||
SheetTrigger
|
||||
} from '@repo/ui/components/ui/sheet'
|
||||
import type { User } from '@repo/auth/auth'
|
||||
|
||||
export const middleware: Route.MiddlewareFunction[] = [authMiddleware]
|
||||
|
||||
export async function loader({ context }: Route.ActionArgs) {
|
||||
const user = context.get(userContext)
|
||||
const user = context.get(userContext) as User
|
||||
return Response.json({ user })
|
||||
}
|
||||
|
||||
@@ -45,8 +46,9 @@ const navMain = [
|
||||
}
|
||||
]
|
||||
|
||||
export default function Component({ loaderData }: Route.ComponentProps) {
|
||||
const { user } = loaderData
|
||||
export default function Component({
|
||||
loaderData: { user }
|
||||
}: Route.ComponentProps) {
|
||||
const [isOpen, { toggle }] = useToggle()
|
||||
|
||||
return (
|
||||
@@ -119,6 +121,12 @@ export default function Component({ loaderData }: Route.ComponentProps) {
|
||||
</header>
|
||||
|
||||
<Outlet />
|
||||
<Toaster
|
||||
position="top-center"
|
||||
richColors={true}
|
||||
duration={Infinity}
|
||||
closeButton={true}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import type { Route } from './+types/emails'
|
||||
|
||||
import { Suspense } from 'react'
|
||||
import { Await } from 'react-router'
|
||||
import {
|
||||
Suspense,
|
||||
type ComponentProps,
|
||||
type MouseEvent,
|
||||
createContext,
|
||||
use
|
||||
} from 'react'
|
||||
|
||||
import { Await } from 'react-router'
|
||||
import { EllipsisIcon, CircleXIcon, SendIcon } from 'lucide-react'
|
||||
import { useRequest, useToggle } from 'ahooks'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Spinner } from '@repo/ui/components/ui/spinner'
|
||||
import { userContext } from '@repo/auth/context'
|
||||
import {
|
||||
Card,
|
||||
@@ -11,17 +22,53 @@ import {
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '@repo/ui/components/ui/card'
|
||||
import type { User } from '@repo/auth/auth'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger
|
||||
} from '@repo/ui/components/ui/alert-dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@repo/ui/components/ui/dropdown-menu'
|
||||
import { request as req } from '@repo/util/request'
|
||||
import { Skeleton } from '@repo/ui/components/skeleton'
|
||||
import { Button } from '@repo/ui/components/ui/button'
|
||||
import { useOutletContext } from 'react-router'
|
||||
import type { User as AuthUser } from '@repo/auth/auth'
|
||||
import type { User } from '@repo/ui/routes/users/data'
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemTitle
|
||||
} from '@repo/ui/components/ui/item'
|
||||
|
||||
type Email = {
|
||||
sk: string
|
||||
email: string
|
||||
email_verified: boolean
|
||||
email_primary: boolean
|
||||
}
|
||||
|
||||
const ActionMenuContext = createContext<Email | null>(null)
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const user = context.get(userContext) as User
|
||||
const user = context.get(userContext) as AuthUser
|
||||
const data = req({
|
||||
url: `/users/${user.sub}/emails`,
|
||||
request,
|
||||
context
|
||||
}).then((r) => r.json())
|
||||
}).then((r) => r.json() as Promise<{ items: Email[] }>)
|
||||
|
||||
return { data }
|
||||
}
|
||||
@@ -40,13 +87,24 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) {
|
||||
principal receberá as mensagens.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul>
|
||||
{items.map(({ sk }: { sk: string }, idx: number) => {
|
||||
const [, email] = sk.split('#')
|
||||
return <li key={idx}>{email}</li>
|
||||
<CardContent className="flex flex-col gap-2.5">
|
||||
{items.map(({ sk, ...props }) => {
|
||||
const [, email] = sk.split('#') as [string, string]
|
||||
|
||||
return (
|
||||
<Item key={email} variant="outline">
|
||||
<ItemContent>
|
||||
<ItemTitle>{email}</ItemTitle>
|
||||
<ItemDescription>...</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<ActionMenuContext value={{ ...props, sk, email }}>
|
||||
<ActionMenu />
|
||||
</ActionMenuContext>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -54,3 +112,94 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) {
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionMenu() {
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon-sm" className="cursor-pointer">
|
||||
<EllipsisIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="*:cursor-pointer">
|
||||
<DropdownMenuItem>
|
||||
<SendIcon /> Reenviar email de verificação
|
||||
</DropdownMenuItem>
|
||||
<RemoveItem />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
type ItemProps = ComponentProps<typeof DropdownMenuItem> & {
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
function RemoveItem({ onSuccess, ...props }: ItemProps) {
|
||||
const { user } = useOutletContext() as { user: User }
|
||||
const { email } = use(ActionMenuContext) as Email
|
||||
const [open, { set: setOpen }] = useToggle(false)
|
||||
const { runAsync, loading } = useRequest(
|
||||
async () => {
|
||||
const r = await fetch(`/api/users/${user.id}/emails/${email}`, {
|
||||
method: 'DELETE',
|
||||
headers: new Headers({ 'Content-Type': 'application/json' })
|
||||
})
|
||||
|
||||
if (!r.ok) {
|
||||
throw await r.json()
|
||||
}
|
||||
},
|
||||
{
|
||||
manual: true
|
||||
}
|
||||
)
|
||||
|
||||
const cancel = async (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
try {
|
||||
await runAsync()
|
||||
|
||||
toast.success('O email foi removido')
|
||||
onSuccess?.()
|
||||
setOpen(false)
|
||||
} catch (err) {
|
||||
// @ts-ignore
|
||||
if (err?.type === 'EmailConflictError') {
|
||||
toast.error('O email não pode ser removido')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
{...props}
|
||||
>
|
||||
<CircleXIcon /> Remover
|
||||
</DropdownMenuItem>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Tem certeza absoluta?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Esta ação não pode ser desfeita. Isso{' '}
|
||||
<span className="font-bold">remove permanentemente</span> o seu
|
||||
endereço de email.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="*:cursor-pointer">
|
||||
<AlertDialogAction asChild>
|
||||
<Button onClick={cancel} disabled={loading} variant="destructive">
|
||||
{loading ? <Spinner /> : null} Continuar
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ export function ModeToggle() {
|
||||
size="icon"
|
||||
className="cursor-pointer text-muted-foreground"
|
||||
>
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
|
||||
<Sun className="size-4 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
|
||||
<Moon className="absolute size-4 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
|
||||
<span className="sr-only">Alternar tema</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
ChevronDown,
|
||||
CirclePlayIcon,
|
||||
DollarSignIcon,
|
||||
GraduationCapIcon,
|
||||
@@ -71,9 +72,12 @@ export function NavUser({
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="cursor-pointer" asChild>
|
||||
<div className="relative">
|
||||
<Avatar className="size-10">
|
||||
<AvatarFallback>{initials(user.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<ChevronDown className="size-3.5 absolute -bottom-px -right-px bg-neutral-700 border border-background rounded-full px-px" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
|
||||
@@ -12,3 +12,4 @@ def get_dynamodb_client():
|
||||
|
||||
dynamodb_client = get_dynamodb_client()
|
||||
s3_client = boto3.client('s3')
|
||||
sesv2_client = boto3.client('sesv2')
|
||||
|
||||
@@ -2,3 +2,5 @@ import os
|
||||
|
||||
USER_TABLE: str = os.getenv('USER_TABLE') # type: ignore
|
||||
CHUNK_SIZE = 50
|
||||
|
||||
EMAIL_SENDER = ('EDUSEG®', 'noreply@eduseg.com.br')
|
||||
|
||||
@@ -11,7 +11,7 @@ from aws_lambda_powertools.utilities.data_classes import (
|
||||
)
|
||||
from aws_lambda_powertools.utilities.typing import LambdaContext
|
||||
from layercake.batch import BatchProcessor
|
||||
from layercake.dateutils import now
|
||||
from layercake.dateutils import now, ttl
|
||||
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
|
||||
from layercake.extra_types import CnpjStr, CpfStr, NameStr
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
@@ -144,6 +144,20 @@ def _create_user(rawuser: dict, context: dict) -> None:
|
||||
'sk': f'emails#{user.email}',
|
||||
'email_verified': False,
|
||||
'email_primary': True,
|
||||
'mx_record_exists': False,
|
||||
'created_at': now_,
|
||||
}
|
||||
)
|
||||
transact.put(
|
||||
item={
|
||||
'id': user_id,
|
||||
'sk': f'EMAIL_VERIFICATION#{uuid4()}',
|
||||
'fresh_user': True,
|
||||
'name': user.name,
|
||||
'email': user.email,
|
||||
'email_primary': True,
|
||||
'org_name': org.name,
|
||||
'ttl': ttl(start_dt=now_, days=30),
|
||||
'created_at': now_,
|
||||
}
|
||||
)
|
||||
|
||||
61
users-events/app/events/send_verification_email.py
Normal file
61
users-events/app/events/send_verification_email.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from aws_lambda_powertools import Logger
|
||||
from aws_lambda_powertools.utilities.data_classes import (
|
||||
EventBridgeEvent,
|
||||
event_source,
|
||||
)
|
||||
from aws_lambda_powertools.utilities.typing import LambdaContext
|
||||
from layercake.email_ import Message
|
||||
from layercake.strutils import first_word
|
||||
|
||||
from boto3clients import sesv2_client
|
||||
from config import EMAIL_SENDER
|
||||
|
||||
SUBJECT = 'Por favor, verifique seu endereço de email na EDUSEG®'
|
||||
MESSAGE = """
|
||||
Oi {first_name}, tudo bem?<br/><br/>
|
||||
|
||||
Para proteger sua conta na EDUSEG, precisamos apenas verificar seu
|
||||
endereço de email: {email}.<br/><br/>
|
||||
|
||||
<a href="https://saladeaula.digital/settings/emails/{code}/verify">
|
||||
👉 Verificar endereço de email
|
||||
</a>
|
||||
"""
|
||||
|
||||
logger = Logger(__name__)
|
||||
|
||||
|
||||
@event_source(data_class=EventBridgeEvent)
|
||||
def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
|
||||
new_image = event.detail['new_image']
|
||||
first_name = first_word(new_image['name'])
|
||||
# Key pattern `EMAIL_VERIFICATION#{hash}`
|
||||
*_, hash = new_image['sk'].split('#')
|
||||
|
||||
emailmsg = Message(
|
||||
from_=EMAIL_SENDER,
|
||||
to=(new_image['name'], new_image['email']),
|
||||
subject=SUBJECT,
|
||||
)
|
||||
emailmsg.add_alternative(
|
||||
MESSAGE.format(
|
||||
first_name=first_name,
|
||||
email=new_image['email'],
|
||||
code=hash,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
sesv2_client.send_email(
|
||||
Content={
|
||||
'Raw': {
|
||||
'Data': emailmsg.as_bytes(),
|
||||
},
|
||||
}
|
||||
)
|
||||
logger.info('Email sent')
|
||||
except Exception as exc:
|
||||
logger.exception(exc)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
61
users-events/app/events/send_welcome_email.py
Normal file
61
users-events/app/events/send_welcome_email.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from aws_lambda_powertools import Logger
|
||||
from aws_lambda_powertools.utilities.data_classes import (
|
||||
EventBridgeEvent,
|
||||
event_source,
|
||||
)
|
||||
from aws_lambda_powertools.utilities.typing import LambdaContext
|
||||
from layercake.email_ import Message
|
||||
from layercake.strutils import first_word
|
||||
|
||||
from boto3clients import sesv2_client
|
||||
from config import EMAIL_SENDER
|
||||
|
||||
SUBJECT = '{first_name} você foi cadastrado na EDUSEG®'
|
||||
MESSAGE = """
|
||||
Oi {first_name}, tudo bem?<br/><br/>
|
||||
|
||||
Sua conta foi criada na EDUSEG pela empresa <b>{org_name}</b>.<br/><br/>
|
||||
|
||||
<a href="https://id.saladeaula.digital/signup?uid={user_id}&code={code}">
|
||||
👉 Faça agora seu primeiro acesso
|
||||
</a>
|
||||
"""
|
||||
|
||||
logger = Logger(__name__)
|
||||
|
||||
|
||||
@event_source(data_class=EventBridgeEvent)
|
||||
def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
|
||||
new_image = event.detail['new_image']
|
||||
# Key pattern `EMAIL_VERIFICATION#{hash}`
|
||||
*_, hash = new_image['sk'].split('#')
|
||||
first_name = first_word(new_image['name'])
|
||||
|
||||
emailmsg = Message(
|
||||
from_=EMAIL_SENDER,
|
||||
to=(new_image['name'], new_image['email']),
|
||||
subject=SUBJECT.format(first_name=first_name),
|
||||
)
|
||||
emailmsg.add_alternative(
|
||||
MESSAGE.format(
|
||||
user_id=new_image['id'],
|
||||
first_name=first_name,
|
||||
org_name=new_image.get('org_name'),
|
||||
code=hash,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
sesv2_client.send_email(
|
||||
Content={
|
||||
'Raw': {
|
||||
'Data': emailmsg.as_bytes(),
|
||||
},
|
||||
}
|
||||
)
|
||||
logger.info('Email sent')
|
||||
except Exception as exc:
|
||||
logger.exception(exc)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
@@ -161,3 +161,63 @@ Resources:
|
||||
new_image:
|
||||
id:
|
||||
- prefix: orgmembers#
|
||||
|
||||
EventSendWelcomeEmailFunction:
|
||||
Type: AWS::Serverless::Function
|
||||
Properties:
|
||||
Handler: events.send_welcome_email.lambda_handler
|
||||
LoggingConfig:
|
||||
LogGroup: !Ref EventLog
|
||||
Policies:
|
||||
- Version: 2012-10-17
|
||||
Statement:
|
||||
- Effect: Allow
|
||||
Action:
|
||||
- ses:SendRawEmail
|
||||
Resource:
|
||||
- !Sub arn:aws:ses:${AWS::Region}:${AWS::AccountId}:identity/eduseg.com.br
|
||||
- !Sub arn:aws:ses:${AWS::Region}:${AWS::AccountId}:configuration-set/tracking
|
||||
Events:
|
||||
DynamoDBEvent:
|
||||
Type: EventBridgeRule
|
||||
Properties:
|
||||
Pattern:
|
||||
resources: [!Ref UserTable]
|
||||
detail-type: [INSERT]
|
||||
detail:
|
||||
new_image:
|
||||
sk:
|
||||
- prefix: EMAIL_VERIFICATION#
|
||||
fresh_user:
|
||||
- exists: true
|
||||
org_name:
|
||||
- exists: true
|
||||
|
||||
EventSendVerificationEmailFunction:
|
||||
Type: AWS::Serverless::Function
|
||||
Properties:
|
||||
Handler: events.send_verification_email.lambda_handler
|
||||
LoggingConfig:
|
||||
LogGroup: !Ref EventLog
|
||||
Policies:
|
||||
- Version: 2012-10-17
|
||||
Statement:
|
||||
- Effect: Allow
|
||||
Action:
|
||||
- ses:SendRawEmail
|
||||
Resource:
|
||||
- !Sub arn:aws:ses:${AWS::Region}:${AWS::AccountId}:identity/eduseg.com.br
|
||||
- !Sub arn:aws:ses:${AWS::Region}:${AWS::AccountId}:configuration-set/tracking
|
||||
Events:
|
||||
DynamoDBEvent:
|
||||
Type: EventBridgeRule
|
||||
Properties:
|
||||
Pattern:
|
||||
resources: [!Ref UserTable]
|
||||
detail-type: [INSERT]
|
||||
detail:
|
||||
new_image:
|
||||
sk:
|
||||
- prefix: EMAIL_VERIFICATION#
|
||||
fresh_user:
|
||||
- exists: false
|
||||
|
||||
Reference in New Issue
Block a user