289 lines
8.4 KiB
TypeScript
289 lines
8.4 KiB
TypeScript
import type { Route } from './+types/index'
|
|
|
|
import {
|
|
Suspense,
|
|
type ComponentProps,
|
|
type MouseEvent,
|
|
createContext,
|
|
use
|
|
} from 'react'
|
|
import { Await, useRevalidator } 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,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle
|
|
} from '@repo/ui/components/ui/card'
|
|
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,
|
|
ItemTitle
|
|
} from '@repo/ui/components/ui/item'
|
|
import { Badge } from '@repo/ui/components/ui/badge'
|
|
|
|
import { type Email } from './data'
|
|
import { Add } from './add'
|
|
import { Primary } from './primary'
|
|
|
|
const ActionMenuContext = createContext<Email | null>(null)
|
|
|
|
export async function loader({ request, context }: Route.LoaderArgs) {
|
|
const user = context.get(userContext) as AuthUser
|
|
const data = req({
|
|
url: `/users/${user.sub}/emails`,
|
|
request,
|
|
context
|
|
}).then((r) => r.json() as Promise<{ items: Email[] }>)
|
|
|
|
return { data }
|
|
}
|
|
|
|
export default function Route({ loaderData: { data } }: Route.ComponentProps) {
|
|
return (
|
|
<div className="space-y-2.5">
|
|
<Suspense fallback={<Skeleton />}>
|
|
<Await resolve={data}>
|
|
{({ items = [] }) => (
|
|
<>
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">Emails</CardTitle>
|
|
<CardDescription>
|
|
Você pode associar vários emails a sua conta. É possível
|
|
usar qualquer email para recuperar a senha, mas apenas o
|
|
email principal receberá as mensagens.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-2.5">
|
|
{items.map(({ sk, email_primary, email_verified }) => {
|
|
const [, email] = sk.split('#') as [string, string]
|
|
|
|
return (
|
|
<Item key={email} variant="outline">
|
|
<ItemContent>
|
|
<ItemTitle>
|
|
{email}{' '}
|
|
{email_primary && (
|
|
<Badge
|
|
variant="outline"
|
|
className="border-blue-500 text-blue-400 lowercase text-xs"
|
|
>
|
|
Principal
|
|
</Badge>
|
|
)}
|
|
{email_verified ? (
|
|
<Badge
|
|
variant="outline"
|
|
className="border-green-600 text-green-500 lowercase text-xs"
|
|
>
|
|
Verificado
|
|
</Badge>
|
|
) : (
|
|
<Badge
|
|
variant="outline"
|
|
className="border-yellow-600 text-yellow-500 lowercase text-xs"
|
|
>
|
|
Não verificado
|
|
</Badge>
|
|
)}
|
|
</ItemTitle>
|
|
</ItemContent>
|
|
<ItemActions>
|
|
<ActionMenuContext
|
|
value={{ sk, email, email_primary, email_verified }}
|
|
>
|
|
<ActionMenu />
|
|
</ActionMenuContext>
|
|
</ItemActions>
|
|
</Item>
|
|
)
|
|
})}
|
|
</CardContent>
|
|
|
|
<CardContent>
|
|
<Add />
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Primary items={items} />
|
|
</>
|
|
)}
|
|
</Await>
|
|
</Suspense>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ActionMenu() {
|
|
const { revalidate } = useRevalidator()
|
|
|
|
const onSuccess = () => {
|
|
revalidate()
|
|
}
|
|
|
|
return (
|
|
<DropdownMenu modal={false}>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon-sm" className="cursor-pointer">
|
|
<EllipsisIcon />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="*:cursor-pointer">
|
|
<ResendItem onSuccess={onSuccess} />
|
|
<RemoveItem onSuccess={onSuccess} />
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)
|
|
}
|
|
|
|
type ItemProps = ComponentProps<typeof DropdownMenuItem> & {
|
|
onSuccess?: () => void
|
|
}
|
|
|
|
function ResendItem({ onSuccess }: ItemProps) {
|
|
const { user } = useOutletContext() as { user: User }
|
|
const { email, email_verified } = use(ActionMenuContext) as Email
|
|
const { runAsync, loading } = useRequest(
|
|
async () => {
|
|
const r = await fetch(
|
|
`/api/users/${user.id}/emails/${email}/request-verification`,
|
|
{
|
|
method: 'POST',
|
|
headers: new Headers({ 'Content-Type': 'application/json' })
|
|
}
|
|
)
|
|
|
|
if (!r.ok) {
|
|
throw await r.json()
|
|
}
|
|
},
|
|
{
|
|
manual: true
|
|
}
|
|
)
|
|
|
|
const resend = async (e: Event) => {
|
|
e.preventDefault()
|
|
|
|
try {
|
|
await runAsync()
|
|
|
|
toast.success(
|
|
`Enviamos um email de verificação para ${email}. Por favor, siga as instruções contidas nele.`
|
|
)
|
|
onSuccess?.()
|
|
} catch {}
|
|
}
|
|
|
|
return (
|
|
<DropdownMenuItem onSelect={resend} disabled={email_verified}>
|
|
{loading ? <Spinner /> : <SendIcon />} Reenviar email de verificação
|
|
</DropdownMenuItem>
|
|
)
|
|
}
|
|
|
|
function RemoveItem({ onSuccess, ...props }: ItemProps) {
|
|
const { user } = useOutletContext() as { user: User }
|
|
const { email, email_primary } = 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.info(`O endereço de email ${email} foi removido.`)
|
|
onSuccess?.()
|
|
} catch (err) {
|
|
// @ts-ignore
|
|
if (err?.type === 'EmailConflictError') {
|
|
toast.error(`O endereço de email ${email} não pode ser removido.`)
|
|
}
|
|
}
|
|
|
|
setOpen(false)
|
|
}
|
|
|
|
if (email_primary) {
|
|
return null
|
|
}
|
|
|
|
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>
|
|
)
|
|
}
|