updat users

This commit is contained in:
2025-11-28 11:41:06 -03:00
parent c8e323163d
commit 2b0efc654a
10 changed files with 196 additions and 78 deletions

View File

@@ -173,6 +173,9 @@ def primary(
now_ = now() now_ = now()
expr = 'SET email_primary = :email_primary, updated_at = :now' expr = 'SET email_primary = :email_primary, updated_at = :now'
if new_email == old_email:
return JSONResponse(status_code=HTTPStatus.NO_CONTENT)
with dyn.transact_writer() as transact: with dyn.transact_writer() as transact:
# Set the old email as non-primary # Set the old email as non-primary
transact.update( transact.update(

View File

@@ -12,7 +12,7 @@ export default [
route('orders', 'routes/orders.tsx'), route('orders', 'routes/orders.tsx'),
route('settings', 'routes/settings/layout.tsx', [ route('settings', 'routes/settings/layout.tsx', [
index('routes/settings/profile.tsx'), index('routes/settings/profile.tsx'),
route('emails', 'routes/settings/emails.tsx'), route('emails', 'routes/settings/emails/index.tsx'),
route('password', 'routes/settings/password.tsx') route('password', 'routes/settings/password.tsx')
]), ]),
route('konviva', 'routes/konviva.ts'), route('konviva', 'routes/konviva.ts'),

View File

@@ -18,13 +18,13 @@ import { useOutletContext } from 'react-router'
import type { User } from '@repo/ui/routes/users/data' import type { User } from '@repo/ui/routes/users/data'
import { useRevalidator } from 'react-router' import { useRevalidator } from 'react-router'
const formSchema = z.object({ export const formSchema = z.object({
email: z.email('Email inválido').trim().toLowerCase() email: z.email('Email inválido').trim().toLowerCase()
}) })
type Schema = z.infer<typeof formSchema> export type Schema = z.infer<typeof formSchema>
export function AddEmail() { export function Add() {
const { revalidate } = useRevalidator() const { revalidate } = useRevalidator()
const { user } = useOutletContext() as { user: User } const { user } = useOutletContext() as { user: User }
const { runAsync } = useRequest( const { runAsync } = useRequest(
@@ -85,7 +85,10 @@ export function AddEmail() {
)} )}
/> />
<Button type="submit" className="relative overflow-hidden"> <Button
type="submit"
className="relative overflow-hidden cursor-pointer"
>
{formState.isSubmitting && ( {formState.isSubmitting && (
<div className="absolute inset-0 bg-lime-500 flex items-center justify-center"> <div className="absolute inset-0 bg-lime-500 flex items-center justify-center">
<Spinner /> <Spinner />

View File

@@ -1,6 +1,13 @@
import { z } from 'zod' import { z } from 'zod'
import { isValidCPF } from '@brazilian-utils/brazilian-utils' import { isValidCPF } from '@brazilian-utils/brazilian-utils'
export type Email = {
sk: string
email: string
email_verified: boolean
email_primary: boolean
}
const isName = (name: string) => name && name.includes(' ') const isName = (name: string) => name && name.includes(' ')
export const formSchema = z.object({ export const formSchema = z.object({

View File

@@ -1,4 +1,4 @@
import type { Route } from './+types/emails' import type { Route } from './+types/index'
import { import {
Suspense, Suspense,
@@ -7,8 +7,7 @@ import {
createContext, createContext,
use use
} from 'react' } from 'react'
import { Await, useRevalidator } from 'react-router'
import { Await } from 'react-router'
import { EllipsisIcon, CircleXIcon, SendIcon } from 'lucide-react' import { EllipsisIcon, CircleXIcon, SendIcon } from 'lucide-react'
import { useRequest, useToggle } from 'ahooks' import { useRequest, useToggle } from 'ahooks'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -51,16 +50,11 @@ import {
ItemContent, ItemContent,
ItemTitle ItemTitle
} from '@repo/ui/components/ui/item' } from '@repo/ui/components/ui/item'
import { AddEmail } from './add-email'
import { Badge } from '@repo/ui/components/ui/badge' import { Badge } from '@repo/ui/components/ui/badge'
import { useRevalidator } from 'react-router'
type Email = { import { type Email } from './data'
sk: string import { Add } from './add'
email: string import { Primary } from './primary'
email_verified: boolean
email_primary: boolean
}
const ActionMenuContext = createContext<Email | null>(null) const ActionMenuContext = createContext<Email | null>(null)
@@ -81,65 +75,69 @@ export default function Route({ loaderData: { data } }: Route.ComponentProps) {
<Suspense fallback={<Skeleton />}> <Suspense fallback={<Skeleton />}>
<Await resolve={data}> <Await resolve={data}>
{({ items = [] }) => ( {({ items = [] }) => (
<Card> <>
<CardHeader> <Card>
<CardTitle className="text-lg">Emails</CardTitle> <CardHeader>
<CardDescription> <CardTitle className="text-lg">Emails</CardTitle>
Você pode associar vários emails a sua conta. É possível usar <CardDescription>
qualquer email para recuperar a senha, mas apenas o email Você pode associar vários emails a sua conta. É possível
principal receberá as mensagens. usar qualquer email para recuperar a senha, mas apenas o
</CardDescription> email principal receberá as mensagens.
</CardHeader> </CardDescription>
<CardContent className="flex flex-col gap-2.5"> </CardHeader>
{items.map(({ sk, email_primary, email_verified }) => { <CardContent className="flex flex-col gap-2.5">
const [, email] = sk.split('#') as [string, string] {items.map(({ sk, email_primary, email_verified }) => {
const [, email] = sk.split('#') as [string, string]
return ( return (
<Item key={email} variant="outline"> <Item key={email} variant="outline">
<ItemContent> <ItemContent>
<ItemTitle> <ItemTitle>
{email}{' '} {email}{' '}
{email_primary && ( {email_primary && (
<Badge <Badge
variant="outline" variant="outline"
className="border-blue-500 text-blue-400 lowercase text-xs" className="border-blue-500 text-blue-400 lowercase text-xs"
> >
Principal Principal
</Badge> </Badge>
)} )}
{email_verified ? ( {email_verified ? (
<Badge <Badge
variant="outline" variant="outline"
className="border-green-600 text-green-500 lowercase text-xs" className="border-green-600 text-green-500 lowercase text-xs"
> >
Verificado Verificado
</Badge> </Badge>
) : ( ) : (
<Badge <Badge
variant="outline" variant="outline"
className="border-yellow-600 text-yellow-500 lowercase text-xs" className="border-yellow-600 text-yellow-500 lowercase text-xs"
> >
Não verificado Não verificado
</Badge> </Badge>
)} )}
</ItemTitle> </ItemTitle>
</ItemContent> </ItemContent>
<ItemActions> <ItemActions>
<ActionMenuContext <ActionMenuContext
value={{ sk, email, email_primary, email_verified }} value={{ sk, email, email_primary, email_verified }}
> >
<ActionMenu /> <ActionMenu />
</ActionMenuContext> </ActionMenuContext>
</ItemActions> </ItemActions>
</Item> </Item>
) )
})} })}
</CardContent> </CardContent>
<CardContent> <CardContent>
<AddEmail /> <Add />
</CardContent> </CardContent>
</Card> </Card>
<Primary items={items} />
</>
)} )}
</Await> </Await>
</Suspense> </Suspense>
@@ -174,10 +172,21 @@ type ItemProps = ComponentProps<typeof DropdownMenuItem> & {
} }
function ResendItem({ onSuccess }: ItemProps) { function ResendItem({ onSuccess }: ItemProps) {
const { user } = useOutletContext() as { user: User }
const { email, email_verified } = use(ActionMenuContext) as Email const { email, email_verified } = use(ActionMenuContext) as Email
const { runAsync, loading } = useRequest( const { runAsync, loading } = useRequest(
async () => { async () => {
return await new Promise((r) => setTimeout(r, 3000)) 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 manual: true

View File

@@ -0,0 +1,96 @@
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useRequest } from 'ahooks'
import { useRevalidator } from 'react-router'
import { toast } from 'sonner'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@repo/ui/components/ui/card'
import { Button } from '@repo/ui/components/ui/button'
import { Kbd } from '@repo/ui/components/ui/kbd'
import {
NativeSelect,
NativeSelectOption
} from '@repo/ui/components/ui/native-select'
import { useOutletContext } from 'react-router'
import type { User } from '@repo/ui/routes/users/data'
import type { Email } from './data'
import { type Schema, formSchema } from './add'
export function Primary({ items = [] }: { items: Email[] }) {
const emails = items.map((props) => {
const [, email] = props.sk.split('#') as [string, string]
return { ...props, email }
}) as Email[]
const primary = emails.find((e) => e.email_primary === true)
const { revalidate } = useRevalidator()
const { user } = useOutletContext() as { user: User }
const { runAsync } = useRequest(
async ({ email }) => {
// Doesn't use `user` because the data could be outdated
const selected = emails.find((e) => e.email === email)
const r = await fetch(`/api/users/${user.id}/emails/primary`, {
method: 'PATCH',
headers: new Headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
new_email: selected?.email,
old_email: primary?.email,
email_verified: selected?.email_verified
})
})
if (!r.ok) {
throw await r.json()
}
},
{ manual: true }
)
const { handleSubmit, register } = useForm({
resolver: zodResolver(formSchema)
})
const onSubmit = async ({ email }: Schema) => {
try {
await runAsync({ email })
toast.success(`O email principal foi alterado para: ${email}.`)
revalidate()
} catch {}
}
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">Email principal</CardTitle>
<CardDescription>
<Kbd className="font-mono border">
<span className="truncate max-lg:max-w-62">{primary?.email}</span>
</Kbd>{' '}
será usado para mensagens e pode ser usado para redefinições de senha.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="flex gap-1.5">
<NativeSelect defaultValue={primary?.email} {...register('email')}>
{emails.map(({ email }) => {
return (
<NativeSelectOption key={email} value={email}>
{email}
</NativeSelectOption>
)
})}
</NativeSelect>
<Button type="submit" className="overflow-hidden cursor-pointer">
Alterar
</Button>
</form>
</CardContent>
</Card>
)
}

View File

@@ -25,7 +25,7 @@ import { Input } from '@repo/ui/components/ui/input'
import { Spinner } from '@repo/ui/components/ui/spinner' import { Spinner } from '@repo/ui/components/ui/spinner'
import { type User } from '@repo/ui/routes/users/data' import { type User } from '@repo/ui/routes/users/data'
import { formSchema, type Schema } from './data' import { formSchema, type Schema } from './emails/data'
import { useFetcher } from 'react-router' import { useFetcher } from 'react-router'
export async function action({ request }: Route.ActionArgs) { export async function action({ request }: Route.ActionArgs) {

View File

@@ -76,7 +76,7 @@ export function NavUser({
<Avatar className="size-10"> <Avatar className="size-10">
<AvatarFallback>{initials(user.name)}</AvatarFallback> <AvatarFallback>{initials(user.name)}</AvatarFallback>
</Avatar> </Avatar>
<ChevronDown className="size-3.5 absolute -bottom-px -right-px bg-neutral-700 border border-background rounded-full px-px" /> <ChevronDown className="size-3.5 absolute -bottom-px -right-px bg-gray-200 dark:bg-neutral-700 border border-background rounded-full px-px" />
</div> </div>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent

View File

@@ -18,7 +18,7 @@ Para proteger sua conta na EDUSEG, precisamos apenas verificar seu
endereço de email: {email}.<br/><br/> endereço de email: {email}.<br/><br/>
<a href="https://saladeaula.digital/settings/emails/{code}/verify"> <a href="https://saladeaula.digital/settings/emails/{code}/verify">
👉 Verificar endereço de email 👉 Clique aqui para verificar endereço de email
</a> </a>
""" """

View File

@@ -17,7 +17,7 @@ Oi {first_name}, tudo bem?<br/><br/>
Sua conta foi criada na EDUSEG pela empresa <b>{org_name}</b>.<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}"> <a href="https://id.saladeaula.digital/signup?uid={user_id}&code={code}">
👉 Faça agora seu primeiro acesso 👉 Clique aqui para fazer seu primeiro acesso
</a> </a>
""" """