This commit is contained in:
2025-11-05 16:26:01 -03:00
parent 488b96dc51
commit 0698cff8cf
76 changed files with 374 additions and 2580 deletions

View File

@@ -0,0 +1,22 @@
{
"name": "@repo/auth",
"version": "0.0.0",
"private": true,
"exports": {
"./auth": "./src/auth.ts",
"./session": "./src/session.ts",
"./context": "./src/context.ts",
"./middleware/*": "./src/middleware/*.ts"
},
"dependencies": {
"jose": "^6.1.0",
"remix-auth-oauth2": "^3.4.1"
},
"devDependencies": {
"react-router": "^7.9.5",
"@types/node": "^24.9.2",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"typescript": "^5.9.3"
}
}

43
packages/auth/src/auth.ts Normal file
View File

@@ -0,0 +1,43 @@
import type { OAuth2Tokens } from 'arctic'
import { decodeJwt } from 'jose'
import { Authenticator } from 'remix-auth'
import { CodeChallengeMethod, OAuth2Strategy } from 'remix-auth-oauth2'
export type User = {
sub: string
email: string
name: string
scope: string
email_verified: boolean
accessToken: string
refreshToken: string
}
export function createAuth(env) {
const authenticator = new Authenticator()
const strategy = new OAuth2Strategy(
{
clientId: env.CLIENT_ID,
clientSecret: env.CLIENT_SECRET,
redirectURI: env.REDIRECT_URI,
authorizationEndpoint: `${env.ISSUER_URL}/authorize`,
tokenEndpoint: `${env.ISSUER_URL}/token`,
tokenRevocationEndpoint: `${env.ISSUER_URL}/revoke`,
scopes: env.SCOPE.split(' '),
codeChallengeMethod: CodeChallengeMethod.S256
},
async ({ tokens }: { tokens: OAuth2Tokens }) => {
const user = decodeJwt(tokens.idToken())
return {
...user,
accessToken: tokens.accessToken(),
refreshToken: tokens.hasRefreshToken() ? tokens.refreshToken() : null
}
}
)
authenticator.use(strategy, 'oidc')
return authenticator
}

View File

@@ -0,0 +1,5 @@
import type { User } from '@/auth'
import { createContext } from 'react-router'
export const userContext = createContext<User | null>(null)
export const requestIdContext = createContext<string | null>(null)

View File

@@ -0,0 +1,70 @@
import { requestIdContext, userContext } from '@/context'
import { createSessionStorage } from '@/session'
import { createAuth, type User } from '@/auth'
import { decodeJwt } from 'jose'
import { redirect, type LoaderFunctionArgs } from 'react-router'
import type { OAuth2Strategy } from 'remix-auth-oauth2'
export const authMiddleware = async (
{ request, context }: LoaderFunctionArgs,
next: () => Promise<Response>
): Promise<Response> => {
const sessionStorage = createSessionStorage(context.cloudflare.env)
const authenticator = createAuth(context.cloudflare.env)
const strategy = authenticator.get<OAuth2Strategy<User>>('oidc')
const session = await sessionStorage.getSession(request.headers.get('cookie'))
const requestId = context.get(requestIdContext)
let user = session.get('user') as User | null
session.set('returnTo', new URL(request.url).toString())
if (!user) {
console.log('There is no user logged in')
return redirect('/login', {
headers: new Headers({
'Set-Cookie': await sessionStorage.commitSession(session)
})
})
}
try {
const accessToken = decodeJwt(user.accessToken) as { exp: number }
const accessTokenExp = accessToken.exp * 1000
const leeway = 120 * 1000 // 2 minutes
if (Date.now() > accessTokenExp - leeway) {
const tokens = await (strategy as any).refreshToken(user.refreshToken)
user = {
...user,
accessToken: tokens.accessToken(),
refreshToken: tokens.refreshToken()
}
console.debug(`[${requestId}] Refresh token retrieved`, user)
// Should replace the user in the session
session.set('user', user)
}
} catch (error) {
console.error(`[${requestId}]`, error?.stack)
// If refreshing the token fails, remove the user from the current session
// so the user is forced to sign in again
session.unset('user')
return redirect('/login', {
headers: new Headers({
'Set-Cookie': await sessionStorage.commitSession(session)
})
})
}
context.set(userContext, user)
const response = await next()
const sessionCookie = await sessionStorage.commitSession(session)
response.headers.set('Set-Cookie', sessionCookie)
return response
}

View File

@@ -0,0 +1,20 @@
import { requestIdContext } from '@/context'
import { type LoaderFunctionArgs } from 'react-router'
export const loggingMiddleware = async (
{ request, context }: LoaderFunctionArgs,
next
) => {
const requestId = crypto.randomUUID()
context.set(requestIdContext, requestId)
console.log(`[${requestId}] ${request.method} ${request.url}`)
const start = performance.now()
const response = await next()
const duration = performance.now() - start
console.log(`[${requestId}] Response ${response.status} (${duration}ms)`)
return response
}

View File

@@ -0,0 +1,16 @@
import { createCookieSessionStorage } from 'react-router'
export function createSessionStorage(env) {
const sessionStorage = createCookieSessionStorage({
cookie: {
name: '__session',
httpOnly: true,
secure: false,
secrets: [env.SESSION_SECRET],
sameSite: 'lax',
path: '/',
maxAge: 86400 * 7 // 7 days
}
})
return sessionStorage
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"moduleResolution": "bundler",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules"]
}

View File

@@ -4,8 +4,6 @@
"private": true,
"exports": {
"./globals.css": "./src/globals.css",
"./postcss.config": "./postcss.config.js",
"./tailwind.config": "./tailwind.config.ts",
"./lib/*": "./src/lib/*.ts",
"./hooks/*": [
"./src/hooks/*.ts",
@@ -15,6 +13,7 @@
"./components/*.svg": "./src/components/*.svg"
},
"dependencies": {
"@brazilian-utils/brazilian-utils": "^1.0.0-rc.12",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
@@ -34,14 +33,22 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"lodash": "^4.17.21",
"lucide-react": "^0.548.0",
"next-themes": "^0.4.6",
"postcss": "^8.5.6",
"react-day-picker": "^9.11.1",
"react-hook-form": "^7.66.0",
"react-number-format": "^5.4.4",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.16",
"tw-animate-css": "^1.4.0",
"zod": "^4.1.12"
},
"devDependencies": {
"@types/lodash": "^4.17.20",
"typescript": "^5.9.2",
"vite": "^7.2.0",
"vite-tsconfig-paths": "^5.1.4"
}
}

View File

@@ -0,0 +1,54 @@
'use client'
import { Moon, Sun, SunMoon } from 'lucide-react'
import { useTheme } from 'next-themes'
import dark from './logo-dark.svg'
import light from './logo-light.svg'
import { Button } from './ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from './ui/dropdown-menu'
export function ModeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="link"
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" />
<span className="sr-only">Alternar tema</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="*:cursor-pointer">
<DropdownMenuItem onClick={() => setTheme('light')}>
<Sun /> Claro
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
<Moon /> Escuro
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
<SunMoon /> Sistema
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
export function ThemedImage() {
return (
<>
<img src={light} className="h-8" data-hide-on-theme="dark" />
<img src={dark} className="h-8" data-hide-on-theme="light" />
</>
)
}

View File

@@ -0,0 +1,148 @@
import { CheckIcon } from 'lucide-react'
import { useState } from 'react'
import { Badge } from '@repo/ui/components/ui/badge'
import { Button } from '@repo/ui/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator
} from '@repo/ui/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger
} from '@repo/ui/components/ui/popover'
import { Separator } from '@repo/ui/components/ui/separator'
import { cn } from '@repo/ui/lib/utils'
interface FacetedFilterProps<TData, TValue> {
value?: string[]
title?: string
icon: React.ComponentType
className?: string
options: {
label: string
value: string
icon?: React.ComponentType<{ className?: string }>
}[]
onChange?: (values: string[]) => void
}
export function FacetedFilter<TData, TValue>({
value = [],
icon: Icon,
title,
options,
onChange,
className
}: FacetedFilterProps<TData, TValue>) {
const [selectedValues, setSelectedValues] = useState(new Set(value))
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn('h-9 border-dashed cursor-pointer', className)}
>
<Icon />
{title}
{selectedValues?.size > 0 && (
<>
<Separator orientation="vertical" className="mx-2 h-4" />
<div className="gap-1 flex">
{selectedValues.size > 2 ? (
<Badge
variant="outline"
className="rounded-sm px-1 font-normal"
>
{selectedValues.size} selecionados
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge
variant="outline"
key={option.value}
className="rounded-sm px-1 font-normal"
>
{option.label}
</Badge>
))
)}
</div>
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder={title} />
<CommandList>
<CommandEmpty>Nenhum resultado encontrado.</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const isSelected = selectedValues.has(option.value)
return (
<CommandItem
className="cursor-pointer"
key={option.value}
onSelect={() => {
if (isSelected) {
selectedValues.delete(option.value)
} else {
selectedValues.add(option.value)
}
setSelectedValues(selectedValues)
onChange?.(Array.from(selectedValues))
}}
>
<div
className={cn(
'flex size-4 items-center justify-center rounded-[4px] border',
isSelected
? 'bg-primary border-primary text-primary-foreground'
: 'border-input [&_svg]:invisible'
)}
>
<CheckIcon className="text-primary-foreground size-3.5" />
</div>
{option.icon && (
<option.icon className="text-muted-foreground size-4" />
)}
<span>{option.label}</span>
</CommandItem>
)
})}
</CommandGroup>
{selectedValues.size > 0 && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => {
setSelectedValues(new Set())
onChange?.([])
}}
className="justify-center text-center cursor-pointer"
>
Limpar
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,173 @@
'use client'
import {
CirclePlayIcon,
DollarSignIcon,
GraduationCapIcon,
LayoutDashboardIcon,
LightbulbIcon,
LogOutIcon,
UserIcon,
type LucideIcon
} from 'lucide-react'
import { Link } from 'react-router'
import { initials } from '../lib/utils'
import { Avatar, AvatarFallback } from './ui/avatar'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from './ui/dropdown-menu'
type NavItem = {
title: string
url: string
icon: LucideIcon
scope?: string[]
}
const apps: NavItem[] = [
{
title: 'Sala de aula',
url: '//scorm.eduseg.workers.dev',
icon: GraduationCapIcon
},
{
title: 'Administrador',
url: '//admin.saladeaula.digital',
icon: LayoutDashboardIcon,
scope: ['apps:admin']
},
{
title: 'EDUSEG® Estúdio',
url: '//studio.saladeaula.digital',
icon: CirclePlayIcon,
scope: ['apps:studio']
},
{
title: 'EDUSEG® Insights',
url: '//insights.saladeaula.digital',
icon: LightbulbIcon,
scope: ['apps:insights']
}
]
export function NavUser({
user
}: {
user: {
name: string
email: string
scope: string
}
}) {
const userScope = user.scope.split(' ')
return (
<DropdownMenu>
<DropdownMenuTrigger className="cursor-pointer" asChild>
<Avatar className="size-10">
<AvatarFallback>{initials(user.name)}</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side="bottom"
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="size-10">
<AvatarFallback>{initials(user.name)}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs text-muted-foreground">
{user.email}
</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
to="//scorm.eduseg.workers.dev/settings"
className="cursor-pointer"
>
<UserIcon />
Minha conta
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
to="//scorm.eduseg.workers.dev/payments"
className="cursor-pointer"
>
<DollarSignIcon />
Histórico de compras
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuGroup>
{grantIfHas(
['apps:admin', 'apps:studio', 'apps:insights'],
userScope,
'any'
) && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-muted-foreground text-sm">
Aplicações
</DropdownMenuLabel>
</>
)}
{apps.map(({ title, url, scope = [], icon: Icon }, idx) => {
if (grantIfHas(scope, userScope)) {
return (
<DropdownMenuItem key={idx} asChild>
<Link to={url} className="cursor-pointer">
<Icon /> {title}
</Link>
</DropdownMenuItem>
)
}
return <></>
})}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/logout" className="cursor-pointer" reloadDocument>
<LogOutIcon />
Sair
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
function grantIfHas(
required: string[],
granted: string[],
mode: 'all' | 'any' = 'all'
): boolean {
const grantedSet: Set<string> = new Set(granted)
if (mode === 'all') {
return required.every((scope) => grantedSet.has(scope))
}
return required.some((scope) => grantedSet.has(scope))
}

View File

@@ -0,0 +1,77 @@
import { debounce } from 'lodash'
import { SearchIcon, XIcon } from 'lucide-react'
import { useRef } from 'react'
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput
} from '@repo/ui/components/ui/input-group'
import { useKeyPress } from '@repo/ui/hooks/use-keypress'
import { cn } from '@repo/ui/lib/utils'
export function SearchForm({
placeholder,
className,
onChange,
defaultValue = '',
...props
}: {
placeholder?: React.ReactNode
className?: string
onChange?: (value: string) => void
defaultValue?: string
} & React.HTMLAttributes<HTMLDivElement>) {
const inputRef = useRef<HTMLInputElement>(null)
useKeyPress('/', () => {
inputRef.current?.focus()
})
const debouncedOnChange = debounce((value: string) => {
onChange?.(value)
}, 200)
return (
<InputGroup className="group">
<InputGroupInput
className={cn('peer', className)}
placeholder=" "
ref={inputRef}
defaultValue={defaultValue}
onChange={(e) => debouncedOnChange(e.target.value)}
{...props}
/>
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
{placeholder && (
<InputGroupAddon className="font-normal hidden peer-focus-within:hidden peer-placeholder-shown:block">
{placeholder}
</InputGroupAddon>
)}
{defaultValue && (
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
className="cursor-pointer"
onClick={() => {
if (inputRef.current) {
inputRef.current.value = ''
}
onChange?.('')
inputRef.current?.focus()
}}
>
<XIcon />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
)
}

View File

@@ -0,0 +1,19 @@
import { Skeleton as XSkeleton } from './ui/skeleton'
export function Skeleton() {
return (
<div className="lg:max-w-2xl mx-auto flex flex-col space-y-3">
<div className="space-y-2">
<XSkeleton className="h-4 w-2/6" />
<XSkeleton className="h-4 w-4/6" />
<XSkeleton className="h-4 w-5/6" />
<XSkeleton className="h-4 w-6/6" />
<XSkeleton className="h-4 w-6/6" />
<XSkeleton className="h-4 w-3/6" />
<XSkeleton className="h-4 w-5/6" />
<XSkeleton className="h-4 w-4/6" />
<XSkeleton className="h-4 w-4/6" />
</div>
</div>
)
}

View File

@@ -0,0 +1,22 @@
import { throttle } from 'lodash'
import { useEffect } from 'react'
export function useKeyPress(targetKey: string, callback: CallableFunction) {
useEffect(() => {
const onKeyDown = throttle((event) => {
if (event.key === targetKey) {
event.preventDefault()
callback(event)
}
}, 300)
window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
onKeyDown.cancel?.()
}
}, [targetKey, callback])
return null
}

View File

@@ -1,4 +1,4 @@
import * as React from "react"
import * as React from 'react'
const MOBILE_BREAKPOINT = 768
@@ -10,9 +10,9 @@ export function useIsMobile() {
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
mql.addEventListener('change', onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
return () => mql.removeEventListener('change', onChange)
}, [])
return !!isMobile

View File

@@ -1,5 +1,13 @@
{
"compilerOptions": {
"composite": true,
"strict": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["vite/client"],
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]

View File

@@ -1,6 +1,7 @@
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
import tsconfigPaths from 'vite-tsconfig-paths'
export default defineConfig({
plugins: [tailwindcss()]
plugins: [tailwindcss(), tsconfigPaths()]
})