This commit is contained in:
2025-11-26 15:14:29 -03:00
parent 0d3d9ac7d3
commit d3ccfb4775
32 changed files with 496 additions and 474 deletions

View File

@@ -64,14 +64,14 @@ Resources:
DefaultAuthorizer: OAuth2Authorizer
Authorizers:
OAuth2Authorizer:
IdentitySource: '$request.header.Authorization'
IdentitySource: $request.header.Authorization
JwtConfiguration:
issuer: 'https://id.saladeaula.digital'
issuer: https://id.saladeaula.digital
audience:
- '6fd6a7ec-c956-4f0b-96d7-337ffec6eabb'
- '1a5483ab-4521-4702-9115-5857ac676851'
- '1db63660-063d-4280-b2ea-388aca4a9459'
- '78a0819e-1f9b-4da1-b05f-40ec0eaed0c8'
- 1a5483ab-4521-4702-9115-5857ac676851 # saladeaula.digital
- 6fd6a7ec-c956-4f0b-96d7-337ffec6eabb # insights.saladeaula.digital
- 1db63660-063d-4280-b2ea-388aca4a9459 # admin.saladeaula.digital
- 78a0819e-1f9b-4da1-b05f-40ec0eaed0c8 # studio.saladeaula.digital
HttpApiFunction:
Type: AWS::Serverless::Function
@@ -122,7 +122,7 @@ Resources:
ScheduleEvent:
Type: ScheduleV2
Properties:
ScheduleExpression: 'cron(*/5 5-23 * * ? *)'
ScheduleExpression: cron(*/5 5-23 * * ? *)
ScheduleExpressionTimezone: America/Sao_Paulo
Outputs:

View File

@@ -1,11 +1,7 @@
import type { Route } from './+types'
import { useToggle } from 'ahooks'
import {
EllipsisVerticalIcon,
PencilIcon,
UserRoundMinusIcon
} from 'lucide-react'
import { EllipsisIcon, PencilIcon, UserRoundMinusIcon } from 'lucide-react'
import { Suspense } from 'react'
import { Await, NavLink, useParams, useRevalidator } from 'react-router'
import { toast } from 'sonner'
@@ -123,7 +119,7 @@ function ActionMenu({ id }: { id: string }) {
className="data-[state=open]:bg-muted text-muted-foreground cursor-pointer absolute z-1 right-4 top-4"
size="icon-sm"
>
<EllipsisVerticalIcon />
<EllipsisIcon />
<span className="sr-only">Abrir menu</span>
</Button>
</DropdownMenuTrigger>
@@ -185,12 +181,12 @@ function RevokeItem({ id }: { id: string }) {
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="*:cursor-pointer">
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction asChild>
<Button onClick={revoke} disabled={loading} variant="destructive">
{loading ? <Spinner /> : null} Continuar
</Button>
</AlertDialogAction>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

View File

@@ -5,7 +5,7 @@ import type { ComponentProps, MouseEvent } from 'react'
import { useRequest, useToggle } from 'ahooks'
import {
CircleXIcon,
EllipsisVerticalIcon,
EllipsisIcon,
FileBadgeIcon,
LockOpenIcon
} from 'lucide-react'
@@ -99,7 +99,7 @@ function ActionMenu({ row }: { row: any }) {
className="data-[state=open]:bg-muted text-muted-foreground cursor-pointer"
size="icon-sm"
>
<EllipsisVerticalIcon />
<EllipsisIcon />
<span className="sr-only">Abrir menu</span>
</Button>
</DropdownMenuTrigger>
@@ -238,12 +238,12 @@ function RemoveDedupItem({
)}
</AlertDialogHeader>
<AlertDialogFooter className="*:cursor-pointer">
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction asChild>
<Button onClick={cancel} disabled={loading} variant="destructive">
{loading ? <Spinner /> : null} Continuar
</Button>
</AlertDialogAction>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
@@ -305,12 +305,12 @@ function CancelItem({
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="*:cursor-pointer">
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction asChild>
<Button onClick={cancel} disabled={loading} variant="destructive">
{loading ? <Spinner /> : null} Continuar
</Button>
</AlertDialogAction>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

View File

@@ -7,7 +7,6 @@ import {
DataTableColumnSelect
} from '@repo/ui/components/data-table'
import { columns as columns_, type Order } from '@repo/ui/routes/orders/columns'
import { Abbr } from '@repo/ui/components/abbr'
export type { Order }

View File

@@ -3,6 +3,7 @@ import type { Route } from './+types/route'
import { zodResolver } from '@hookform/resolvers/zod'
import { PatternFormat } from 'react-number-format'
import { Link, useOutletContext } from 'react-router'
import { useForm } from 'react-hook-form'
import { Button } from '@repo/ui/components/ui/button'
import {
@@ -22,15 +23,14 @@ import {
} from '@repo/ui/components/ui/form'
import { Input } from '@repo/ui/components/ui/input'
import { Spinner } from '@repo/ui/components/ui/spinner'
import { useForm } from 'react-hook-form'
import { type User } from '@repo/ui/routes/users/data'
import type { User } from '../_.$orgid.users.$id/route'
import { formSchema, type Schema } from '../_.$orgid.users.add/route'
import { formSchema, type Schema } from '../_.$orgid.users.add/data'
export default function Route({}: Route.ComponentProps) {
const { user } = useOutletContext() as { user: User }
const form = useForm({
defaultValues: user,
defaultValues: { ...user, given_email: false },
resolver: zodResolver(formSchema)
})
const { handleSubmit, control, formState } = form

View File

@@ -40,8 +40,8 @@ export async function loader({ params, request, context }: Route.LoaderArgs) {
throw new Response(null, { status: r.status })
}
const user: User = await r.json()
return { user }
const data = await r.json()
return { data } as { data: any }
}
export function shouldRevalidate({
@@ -51,18 +51,14 @@ export function shouldRevalidate({
return currentParams.id !== nextParams.id
}
export type User = {
name: string
email: string
cpf: string
}
const links = [
{ to: '', title: 'Perfil', end: true },
{ to: 'emails', title: 'Emails' }
]
export default function Route({ loaderData: { user } }: Route.ComponentProps) {
export default function Route({
loaderData: { data: user }
}: Route.ComponentProps) {
return (
<div className="space-y-2.5">
<Breadcrumb>

View File

@@ -2,11 +2,7 @@
import { type ColumnDef } from '@tanstack/react-table'
import { useToggle } from 'ahooks'
import {
EllipsisVerticalIcon,
PencilIcon,
UserRoundMinusIcon
} from 'lucide-react'
import { EllipsisIcon, PencilIcon, UserRoundMinusIcon } from 'lucide-react'
import { NavLink, useParams } from 'react-router'
import { toast } from 'sonner'
@@ -63,7 +59,7 @@ function ActionMenu({ row }: { row: any }) {
className="data-[state=open]:bg-muted text-muted-foreground cursor-pointer"
size="icon-sm"
>
<EllipsisVerticalIcon />
<EllipsisIcon />
<span className="sr-only">Abrir menu</span>
</Button>
</DropdownMenuTrigger>
@@ -127,12 +123,12 @@ function UnlinkItem({ id }: { id: string }) {
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="*:cursor-pointer">
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction asChild>
<Button onClick={unlink} disabled={loading} variant="destructive">
{loading ? <Spinner /> : null} Continuar
</Button>
</AlertDialogAction>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

View File

@@ -0,0 +1,55 @@
import { isValidCPF } from '@brazilian-utils/brazilian-utils'
import {
adjectives,
colors,
NumberDictionary,
uniqueNamesGenerator
} from 'unique-names-generator'
import { z } from 'zod'
const isName = (name: string) => name && name.includes(' ')
function randomEmail() {
const numberDict = NumberDictionary.generate({ min: 100, max: 999 })
const randomName: string = uniqueNamesGenerator({
dictionaries: [adjectives, colors, numberDict],
length: 3,
separator: '-'
})
return `${randomName}@users.noreply.saladeaula.digital`
}
export const formSchema = z
.object({
name: z
.string()
.trim()
.nonempty('Digite um nome')
.refine(isName, { message: 'Nome inválido' }),
email: z.string().trim().toLowerCase().optional(),
cpf: z
.string('CPF obrigatório')
.refine(isValidCPF, { message: 'CPF inválido' }),
given_email: z.coerce.boolean()
})
.refine(
({ given_email, email }) => {
if (given_email) {
return true
}
return email && z.email().safeParse(email).success
},
{
message: 'Email inválido',
path: ['email']
}
)
.transform((data) => {
if (data.given_email) {
return { ...data, email: randomEmail() }
}
return data
})
export type Schema = z.infer<typeof formSchema>

View File

@@ -1,18 +1,11 @@
import type { Route } from './+types/route'
import { isValidCPF } from '@brazilian-utils/brazilian-utils'
import { useEffect } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { PatternFormat } from 'react-number-format'
import { Link, useFetcher } from 'react-router'
import { toast } from 'sonner'
import {
adjectives,
colors,
NumberDictionary,
uniqueNamesGenerator
} from 'unique-names-generator'
import { z } from 'zod'
import {
Breadcrumb,
@@ -44,54 +37,7 @@ import { Spinner } from '@repo/ui/components/ui/spinner'
import { useWorksapce } from '@/components/workspace-switcher'
import { HttpMethod, request as req } from '@repo/util/request'
import { useEffect } from 'react'
const isName = (name: string) => name && name.includes(' ')
function randomEmail() {
const numberDict = NumberDictionary.generate({ min: 100, max: 999 })
const randomName: string = uniqueNamesGenerator({
dictionaries: [adjectives, colors, numberDict],
length: 3,
separator: '-'
})
return `${randomName}@users.noreply.saladeaula.digital`
}
export const formSchema = z
.object({
name: z
.string()
.trim()
.nonempty('Digite um nome')
.refine(isName, { message: 'Nome inválido' }),
email: z.string().trim().toLowerCase().optional(),
cpf: z
.string('CPF obrigatório')
.refine(isValidCPF, { message: 'CPF inválido' }),
given_email: z.coerce.boolean()
})
.refine(
({ given_email, email }) => {
if (given_email) {
return true
}
return email && z.email().safeParse(email).success
},
{
message: 'Email inválido',
path: ['email']
}
)
.transform((data) => {
if (data.given_email) {
return { ...data, email: randomEmail() }
}
return data
})
export type Schema = z.infer<typeof formSchema>
import { formSchema, type Schema } from './data'
export function meta({}: Route.MetaArgs) {
return [{ title: 'Adicionar colaborador' }]

View File

@@ -2,10 +2,6 @@ import type { Route } from './+types'
import { parse } from 'cookie'
export const OK = 200
export const FOUND = 302
export const INTERNAL_SERVER_ERROR = 500
export async function loader({ request, context }: Route.LoaderArgs) {
const cookies = parse(request.headers.get('Cookie') || '')
const url = new URL(request.url)
@@ -16,7 +12,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
if (!cookies?.__session) {
return new Response(null, {
status: FOUND,
status: 302,
headers: {
Location: loginUrl.toString()
}
@@ -33,7 +29,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
redirect: 'manual'
})
if (r.status === FOUND) {
if (r.status === 302) {
return new Response(await r.text(), {
status: r.status,
headers: r.headers
@@ -47,7 +43,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
})
// Deny authorization if user lacks scopes requested by client
if (r.status === FOUND) {
if (r.status === 302) {
return new Response(null, {
status: r.status,
headers: {
@@ -57,13 +53,13 @@ export async function loader({ request, context }: Route.LoaderArgs) {
}
return new Response(null, {
status: FOUND,
status: 302,
headers: {
Location: loginUrl.toString()
}
})
} catch (error) {
console.error(error)
return new Response(null, { status: INTERNAL_SERVER_ERROR })
return new Response(null, { status: 500 })
}
}

View File

@@ -79,7 +79,7 @@ export async function action({ request, context }: Route.ActionArgs) {
})
} catch (error) {
console.error(error)
return Response.json({}, { status: INTERNAL_SERVER_ERROR })
return Response.json({}, { status: 500 })
}
}

View File

@@ -20,8 +20,8 @@ export default function Layout() {
aria-hidden="true"
className="absolute inset-0 grid grid-cols-2 opacity-20"
>
<div className="blur-[106px] h-56 bg-gradient-to-br to-lime-400 from-lime-700"></div>
<div className="blur-[106px] h-42 bg-gradient-to-r from-lime-400 to-lime-600"></div>
<div className="blur-[106px] h-56 bg-linear-to-br to-lime-400 from-lime-700"></div>
<div className="blur-[106px] h-42 bg-linear-to-r from-lime-400 to-lime-600"></div>
</div>
</div>
</div>

View File

@@ -2,7 +2,7 @@
import type { ColumnDef } from '@tanstack/react-table'
import { useToggle } from 'ahooks'
import { EllipsisVerticalIcon, FileBadgeIcon } from 'lucide-react'
import { EllipsisIcon, FileBadgeIcon } from 'lucide-react'
import type { ComponentProps } from 'react'
import { Button } from '@repo/ui/components/ui/button'
@@ -57,7 +57,7 @@ function ActionMenu({ row }: { row: any }) {
className="data-[state=open]:bg-muted text-muted-foreground cursor-pointer"
size="icon-sm"
>
<EllipsisVerticalIcon />
<EllipsisIcon />
<span className="sr-only">Abrir menu</span>
</Button>
</DropdownMenuTrigger>

View File

@@ -1,7 +1,7 @@
'use client'
import { type ColumnDef } from '@tanstack/react-table'
import { EllipsisVerticalIcon } from 'lucide-react'
import { EllipsisIcon } from 'lucide-react'
import { Button } from '@repo/ui/components/ui/button'
import {
@@ -81,7 +81,7 @@ function ActionMenu({ row }: { row: any }) {
className="data-[state=open]:bg-muted text-muted-foreground cursor-pointer"
size="icon-sm"
>
<EllipsisVerticalIcon />
<EllipsisIcon />
<span className="sr-only">Abrir menu</span>
</Button>
</DropdownMenuTrigger>

View File

@@ -1,7 +1,7 @@
'use client'
import { type ColumnDef } from '@tanstack/react-table'
import { EllipsisVerticalIcon } from 'lucide-react'
import { EllipsisIcon } from 'lucide-react'
import { Abbr } from '@repo/ui/components/abbr'
import { Button } from '@repo/ui/components/ui/button'
@@ -78,7 +78,7 @@ function ActionMenu({ row }: { row: any }) {
className="data-[state=open]:bg-muted text-muted-foreground cursor-pointer"
size="icon-sm"
>
<EllipsisVerticalIcon />
<EllipsisIcon />
<span className="sr-only">Abrir menu</span>
</Button>
</DropdownMenuTrigger>

View File

@@ -2,7 +2,7 @@
import { useBoolean } from 'ahooks'
import { type ColumnDef } from '@tanstack/react-table'
import { CheckIcon, CopyIcon, EllipsisVerticalIcon } from 'lucide-react'
import { CheckIcon, CopyIcon, EllipsisIcon } from 'lucide-react'
import {
DataTableColumnSelect,
@@ -46,7 +46,7 @@ function ActionMenu({ row }: { row: any }) {
className="data-[state=open]:bg-muted text-muted-foreground cursor-pointer"
size="icon-sm"
>
<EllipsisVerticalIcon />
<EllipsisIcon />
<span className="sr-only">Abrir menu</span>
</Button>
</DropdownMenuTrigger>

View File

@@ -10,7 +10,11 @@ export default [
index('routes/index.tsx'),
route('certs', 'routes/certs.tsx'),
route('orders', 'routes/orders.tsx'),
route('settings', 'routes/settings.tsx'),
route('settings', 'routes/settings/layout.tsx', [
index('routes/settings/profile.tsx'),
route('emails', 'routes/settings/emails.tsx'),
route('password', 'routes/settings/password.tsx')
]),
route('konviva', 'routes/konviva.ts'),
route('player/:id', 'routes/player.tsx'),
route('proxy/*', 'routes/proxy.tsx')

View File

@@ -1,160 +0,0 @@
import type { Route } from './+types'
import { useForm } from 'react-hook-form'
import { Link } from 'react-router'
import { Container } from '@/components/container'
import type { User } from '@repo/auth/auth'
import { userContext } from '@repo/auth/context'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from '@repo/ui/components/ui/breadcrumb'
import { Button } from '@repo/ui/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@repo/ui/components/ui/card'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@repo/ui/components/ui/form'
import { Input } from '@repo/ui/components/ui/input'
import { Spinner } from '@repo/ui/components/ui/spinner'
import { request as req } from '@repo/util/request'
export function meta({}: Route.MetaArgs) {
return [{ title: 'Minha conta' }]
}
export async function loader({ context, request }: Route.ActionArgs) {
const user = context.get(userContext) as User
const r = await req({
url: `/users/${user.sub}`,
request,
context
})
return { user: await r.json() }
}
export default function Route({ loaderData: { user } }) {
const form = useForm({ defaultValues: user })
const { handleSubmit, control, formState } = form
const onSubmit = async (data) => {
console.log(data)
}
return (
<Container className="space-y-2.5">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to="..">Meus cursos</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Minha conta</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="lg:max-w-2xl mx-auto space-y-2.5">
<Form {...form}>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-2xl">Minha conta</CardTitle>
<CardDescription>
Gerenciar as configurações da sua conta.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Nome</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="email"
disabled={true}
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormLabel className="text-sm font-normal text-muted-foreground">
<span>
Para gerenciar os emails ou trocar o email principal,
use as{' '}
<Link
to="emails"
className="text-blue-400 underline hover:no-underline"
>
configurações de emails
</Link>
</span>
</FormLabel>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="cpf"
render={({ field }) => (
<FormItem>
<FormLabel>CPF</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<div className="flex justify-end">
<Button
type="submit"
className="cursor-pointer"
disabled={formState.isSubmitting}
>
{formState.isSubmitting && <Spinner />}
Editar
</Button>
</div>
</form>
</Form>
</div>
</Container>
)
}

View File

@@ -0,0 +1,18 @@
import { z } from 'zod'
import { isValidCPF } from '@brazilian-utils/brazilian-utils'
const isName = (name: string) => name && name.includes(' ')
export const formSchema = z.object({
name: z
.string()
.trim()
.nonempty('Digite um nome')
.refine(isName, { message: 'Nome inválido' }),
email: z.string().trim().toLowerCase(),
cpf: z
.string('CPF obrigatório')
.refine(isValidCPF, { message: 'CPF inválido' })
})
export type Schema = z.infer<typeof formSchema>

View File

@@ -0,0 +1,56 @@
import type { Route } from './+types/emails'
import { Suspense } from 'react'
import { Await } from 'react-router'
import { userContext } from '@repo/auth/context'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@repo/ui/components/ui/card'
import type { User } from '@repo/auth/auth'
import { request as req } from '@repo/util/request'
import { Skeleton } from '@repo/ui/components/skeleton'
export async function loader({ request, context }: Route.LoaderArgs) {
const user = context.get(userContext) as User
const data = req({
url: `/users/${user.sub}/emails`,
request,
context
}).then((r) => r.json())
return { data }
}
export default function Route({ loaderData: { data } }: Route.ComponentProps) {
return (
<Suspense fallback={<Skeleton />}>
<Await resolve={data}>
{({ items = [] }) => (
<Card>
<CardHeader>
<CardTitle className="text-lg">Emails</CardTitle>
<CardDescription>
Podem ser associados 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>
<ul>
{items.map(({ sk }: { sk: string }, idx: number) => {
const [, email] = sk.split('#')
return <li key={idx}>{email}</li>
})}
</ul>
</CardContent>
</Card>
)}
</Await>
</Suspense>
)
}

View File

@@ -0,0 +1,100 @@
import type { Route } from './+types/layout'
import {
NavLink,
Outlet,
Link,
type ShouldRevalidateFunctionArgs
} from 'react-router'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from '@repo/ui/components/ui/breadcrumb'
import { Tabs, TabsList, TabsTrigger } from '@repo/ui/components/ui/tabs'
import { request as req } from '@repo/util/request'
import { Container } from '@/components/container'
import { userContext } from '@repo/auth/context'
import type { User } from '@repo/auth/auth'
export function meta({}: Route.MetaArgs) {
return [{ title: 'Minha conta' }]
}
export async function loader({ request, context }: Route.LoaderArgs) {
const user = context.get(userContext) as User
const r = await req({
url: `/users/${user.sub}`,
request,
context
})
if (!r.ok) {
throw new Response(null, { status: r.status })
}
const data = await r.json()
return { data }
}
export function shouldRevalidate({}: ShouldRevalidateFunctionArgs) {
return false
}
const links = [
{ to: '', title: 'Perfil', end: true },
{ to: 'emails', title: 'Emails' },
{ to: 'password', title: 'Senha' }
]
export default function Layout({ loaderData: { data } }: Route.ComponentProps) {
return (
<Container className="space-y-2.5">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to="..">Meus cursos</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Minha conta</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="lg:max-w-2xl mx-auto space-y-2.5">
<Tabs>
<TabsList>
{links.map(({ to, title, ...props }, idx) => (
<NavLink
to={to}
key={idx}
className="aria-[current=page]:pointer-events-none"
{...props}
>
{({ isActive }) => (
<TabsTrigger
data-state={isActive ? 'active' : ''}
value={title}
asChild
>
<span>{title}</span>
</TabsTrigger>
)}
</NavLink>
))}
</TabsList>
</Tabs>
<Outlet context={{ user: data }} />
</div>
</Container>
)
}

View File

@@ -0,0 +1,5 @@
import type { Route } from './+types/emails'
export default function Route({}: Route.ComponentProps) {
return <></>
}

View File

@@ -0,0 +1,145 @@
import type { Route } from './+types/profile'
import { useForm } from 'react-hook-form'
import { Link, useOutletContext } from 'react-router'
import { PatternFormat } from 'react-number-format'
import { zodResolver } from '@hookform/resolvers/zod'
import { Button } from '@repo/ui/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@repo/ui/components/ui/card'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@repo/ui/components/ui/form'
import { Input } from '@repo/ui/components/ui/input'
import { Spinner } from '@repo/ui/components/ui/spinner'
import { type User } from '@repo/ui/routes/users/data'
import { formSchema, type Schema } from './data'
import { useFetcher } from 'react-router'
export async function action({ request }: Route.ActionArgs) {
const body = await request.json()
console.log(body)
return { ok: true }
}
export default function Route({}: Route.ComponentProps) {
const { user } = useOutletContext() as { user: User }
const fetcher = useFetcher()
const form = useForm({
defaultValues: user,
resolver: zodResolver(formSchema)
})
const { handleSubmit, control, formState } = form
const onSubmit = async (data: Schema) => {
await fetcher.submit(JSON.stringify({ id: user.id, ...data }), {
method: 'post',
encType: 'application/json'
})
}
return (
<Form {...form}>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-2xl">Minha conta</CardTitle>
<CardDescription>
Gerenciar as configurações da sua conta.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Nome</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="email"
disabled={true}
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormLabel className="text-sm font-normal text-muted-foreground">
<span>
Para gerenciar os emails ou trocar o email principal, use
as{' '}
<Link
to="emails"
className="text-blue-400 underline hover:no-underline"
>
configurações de emails
</Link>
</span>
</FormLabel>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="cpf"
render={({ field: { onChange, ref, ...props } }) => (
<FormItem>
<FormLabel>CPF</FormLabel>
<FormControl>
<PatternFormat
format="###.###.###-##"
mask="_"
placeholder="___.___.___-__"
customInput={Input}
getInputRef={ref}
onValueChange={({ value }) => {
onChange(value)
}}
{...props}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<div className="flex justify-end">
<Button
type="submit"
className="cursor-pointer"
disabled={formState.isSubmitting}
>
{formState.isSubmitting && <Spinner />}
Editar
</Button>
</div>
</form>
</Form>
)
}

View File

@@ -253,8 +253,8 @@ function Editing() {
<FormItem
className="flex flex-row items-center justify-between
rounded-lg border p-3 shadow-sm
dark:has-[[aria-checked=true]]:border-blue-900
dark:has-[[aria-checked=true]]:bg-blue-950"
dark:has-aria-checked:border-blue-900
dark:has-aria-checked:bg-blue-950"
>
<div className="space-y-1">
<FormLabel>Habilitar certificação</FormLabel>
@@ -383,7 +383,7 @@ function Editing() {
{...field}
/>
</FormControl>
<FormLabel>Ocultar o curso no catálogo.</FormLabel>
<FormLabel>Ocultar o curso no catálogo</FormLabel>
</FormItem>
)}
/>

View File

@@ -1,7 +1,13 @@
import type { Route } from './+types/index'
import Fuse from 'fuse.js'
import { AwardIcon, BanIcon, FileBadgeIcon, LaptopIcon } from 'lucide-react'
import {
AwardIcon,
BanIcon,
FileBadgeIcon,
HatGlassesIcon,
LaptopIcon
} from 'lucide-react'
import { Suspense, useMemo } from 'react'
import { Await, NavLink, useSearchParams } from 'react-router'
@@ -31,6 +37,7 @@ import {
import type { Course } from './edit'
import placeholder from '@/assets/placeholder.webp'
import { cn } from '@repo/ui/lib/utils'
export function meta({}: Route.MetaArgs) {
return [{ title: 'Gerenciar seus cursos' }]
@@ -131,7 +138,12 @@ function Course({ id, name, access_period, cert, draft }: Course) {
return (
<NavLink to={`/edit/${id}`} className="hover:scale-105 transition">
{({ isPending }) => (
<Card className="overflow-hidden relative h-96">
<Card
className={cn(
'overflow-hidden relative h-96',
draft && 'border-dashed'
)}
>
{isPending && (
<div className="absolute bottom-0 right-0 p-6 z-1">
<Spinner className="size-6" />
@@ -139,12 +151,7 @@ function Course({ id, name, access_period, cert, draft }: Course) {
)}
<CardHeader className="z-1 relative">
<CardTitle className="text-xl/6">
{name}{' '}
{draft ? (
<span className="text-muted-foreground">(rascunho)</span>
) : null}
</CardTitle>
<CardTitle className="text-xl/6">{name}</CardTitle>
</CardHeader>
<CardFooter className="text-gray-300 text-sm absolute z-1 bottom-6 w-full flex gap-1.5">
@@ -180,6 +187,12 @@ function Course({ id, name, access_period, cert, draft }: Course) {
<FileBadgeIcon className="size-4" />
</li>
)}
{draft && (
<li className="flex items-center">
<HatGlassesIcon className="size-4" />
</li>
)}
</ul>
</CardFooter>

View File

@@ -7,24 +7,26 @@ from aws_lambda_powertools.event_handler.api_gateway import (
from aws_lambda_powertools.logging import correlation_paths
from aws_lambda_powertools.utilities.typing import LambdaContext
from routes.authentication import router as authentication
from routes.authorize import router as authorize
from routes.jwks import router as jwks
from routes.openid_configuration import router as openid_configuration
from routes.register import router as register
from routes.revoke import router as revoke
from routes.session import router as session
from routes.token import router as token
from routes.userinfo import router as userinfo
logger = Logger(__name__)
tracer = Tracer()
app = APIGatewayHttpResolver(enable_validation=True)
app.include_router(session)
app.include_router(authentication)
app.include_router(authorize)
app.include_router(jwks)
app.include_router(openid_configuration)
app.include_router(register)
app.include_router(revoke)
app.include_router(token)
app.include_router(userinfo)
app.include_router(revoke)
app.include_router(openid_configuration)
@app.get('/health')

View File

@@ -198,23 +198,6 @@ class AuthorizationCodeGrant(grants.AuthorizationCodeGrant):
)
class TokenExchangeGrant(grants.BaseGrant):
GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:token-exchange'
TOKEN_ENDPOINT_AUTH_METHODS = [
'client_secret_basic',
'client_secret_post',
]
@hooked
def validate_token_request(self):
raise NotImplementedError()
@hooked
def create_token_response(self):
raise NotImplementedError()
class RefreshTokenGrant(grants.RefreshTokenGrant):
TOKEN_ENDPOINT_AUTH_METHODS = [
'client_secret_basic',
@@ -280,6 +263,23 @@ class RefreshTokenGrant(grants.RefreshTokenGrant):
)
class TokenExchangeGrant(grants.BaseGrant):
GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:token-exchange'
TOKEN_ENDPOINT_AUTH_METHODS = [
'client_secret_basic',
'client_secret_post',
]
@hooked
def validate_token_request(self):
raise NotImplementedError()
@hooked
def create_token_response(self):
raise NotImplementedError()
class RevocationEndpoint(rfc7009.RevocationEndpoint):
def query_token( # type: ignore
self,

View File

@@ -24,7 +24,7 @@ dyn = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client)
def authorize():
current_event = router.current_event
cookies = parse_cookies(current_event.get('cookies', []))
session = cookies.get('__session')
session = cookies.get('SID')
if not session:
raise BadRequestError('Missing session')

View File

@@ -1,158 +0,0 @@
from http import HTTPStatus
from typing import Annotated
from uuid import uuid4
import boto3
from aws_lambda_powertools.event_handler import (
Response,
)
from aws_lambda_powertools.event_handler.api_gateway import Router
from aws_lambda_powertools.event_handler.exceptions import ForbiddenError, NotFoundError
from aws_lambda_powertools.event_handler.openapi.params import Body
from aws_lambda_powertools.shared.cookies import Cookie
from layercake.dateutils import now, ttl
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey
from passlib.hash import pbkdf2_sha256
from boto3clients import dynamodb_client
from config import (
OAUTH2_TABLE,
SESSION_EXPIRES_IN,
)
router = Router()
dyn = DynamoDBPersistenceLayer(OAUTH2_TABLE, dynamodb_client)
idp = boto3.client('cognito-idp')
@router.post('/session')
def session(
username: Annotated[str, Body()],
password: Annotated[str, Body()],
):
user_id, password_hash = _get_user(username)
if not password_hash:
_get_idp_user(user_id, username, password)
else:
if not pbkdf2_sha256.verify(password, password_hash):
raise ForbiddenError('Invalid credentials')
return Response(
status_code=HTTPStatus.OK,
cookies=[
Cookie(
name='__session',
value=new_session(user_id),
http_only=True,
secure=True,
same_site=None,
max_age=SESSION_EXPIRES_IN,
)
],
)
def _get_user(username: str) -> tuple[str, str | None]:
sk = SortKey(username, path_spec='user_id')
user = dyn.collection.get_items(
KeyPair(pk='email', sk=sk, rename_key=sk.path_spec)
+ KeyPair(pk='cpf', sk=sk, rename_key=sk.path_spec),
)
if not user:
raise UserNotFoundError()
password = dyn.collection.get_item(
KeyPair(
pk=user['user_id'],
sk=SortKey(
sk='PASSWORD',
path_spec='hash',
rename_key='password',
),
),
raise_on_error=False,
default=None,
# Uncomment the following line when removing support for Cognito
# exc_cls=UserNotFoundError,
)
return user['user_id'], password
def _get_idp_user(
user_id: str,
username: str,
password: str,
) -> bool:
import base64
import hashlib
import hmac
# That should be removed when completing the migration
# to our own OAuth2 implementation.
client_id = '3ijacqc7r2jc9l4oli2b41f7te'
client_secret = 'amktf9l40g1mlqdo9fjlcfvpn2cp3mvh4pt97hu55sfelccos58'
dig = hmac.new(
client_secret.encode('utf-8'),
msg=(username + client_id).encode('utf-8'),
digestmod=hashlib.sha256,
).digest()
try:
idp.initiate_auth(
AuthFlow='USER_PASSWORD_AUTH',
AuthParameters={
'USERNAME': username,
'PASSWORD': password,
'SECRET_HASH': base64.b64encode(dig).decode(),
},
ClientId=client_id,
)
dyn.put_item(
item={
'id': user_id,
'sk': 'PASSWORD',
'hash': pbkdf2_sha256.hash(password),
'created_at': now(),
}
)
except Exception:
raise ForbiddenError('Invalid credentials')
return True
def new_session(sub: str) -> str:
sid = str(uuid4())
now_ = now()
exp = ttl(start_dt=now_, seconds=SESSION_EXPIRES_IN)
with dyn.transact_writer() as transact:
transact.put(
item={
'id': 'SESSION',
'sk': sid,
'user_id': sub,
'ttl': exp,
'created_at': now_,
}
)
transact.put(
item={
'id': sub,
'sk': f'SESSION#{sid}',
'ttl': exp,
'created_at': now_,
}
)
return f'{sid}:{sub}'
class UserNotFoundError(NotFoundError):
def __init__(self, *_):
super().__init__('User not found')

View File

@@ -56,10 +56,16 @@ Resources:
- cognito-idp:InitiateAuth
Resource: !Sub arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/*
Events:
Session:
Authentication:
Type: HttpApi
Properties:
Path: /session
Path: /authentication
Method: POST
ApiId: !Ref HttpApi
Register:
Type: HttpApi
Properties:
Path: /register
Method: POST
ApiId: !Ref HttpApi
Authorize:

View File

@@ -271,7 +271,10 @@ export function DataTable<TData, TValue>({
<TableRow
key={row.id}
data-state={isSelected && 'selected'}
className="group has-data-[state=open]:bg-muted transition-none hover:bg-muted relative z-5"
className="group has-data-[state=open]:bg-muted
data-[state=selected]:bg-gray-50
dark:data-[state=selected]:bg-neutral-900
transition-none hover:bg-muted relative z-5"
>
{row.getVisibleCells().map((cell) => {
const isPinned = cell.column.getIsPinned()
@@ -281,8 +284,13 @@ export function DataTable<TData, TValue>({
key={cell.id}
className={cn(
isPinned &&
'lg:sticky z-1 bg-card group-hover:bg-muted group-has-data-[state=open]:bg-muted',
isPinned && isSelected && 'bg-muted',
'lg:sticky z-1 bg-card group-has-data-[state=open]:bg-muted',
isPinned &&
!isSelected &&
'group-hover:bg-muted',
isPinned &&
isSelected &&
'bg-gray-50 dark:bg-neutral-900',
isPinned === 'left' && 'left-0',
isPinned === 'right' && 'right-0',
// Override the shadcn class

View File

@@ -4,8 +4,7 @@ export type User = {
id: string
name: string
email: string
cpf?: string
cnpj?: string
cpf: string
}
export const headers = {