feat: add auth page with login/register and enable task3 CORS origin

This commit is contained in:
Gabriel Menezes 2026-10-10 21:34:38 -04:00
parent 727cf62f7d
commit 8015b3ff77
13 changed files with 856 additions and 95 deletions

View file

@ -1,7 +1,7 @@
quarkus.http.port=8081 quarkus.http.port=8081
quarkus.http.cors.enabled=true quarkus.http.cors.enabled=true
quarkus.http.cors.origins=https://task2.beetech.dev quarkus.http.cors.origins=https://task2.beetech.dev,https://task3.beetech.dev
quarkus.http.cors.methods=GET,PUT,POST,DELETE,PATCH,OPTIONS quarkus.http.cors.methods=GET,PUT,POST,DELETE,PATCH,OPTIONS
quarkus.http.cors.exposed-headers=Content-Disposition quarkus.http.cors.exposed-headers=Content-Disposition
quarkus.http.cors.access-control-max-age=24H quarkus.http.cors.access-control-max-age=24H

View file

@ -76,7 +76,7 @@ services:
DB_USER: beetask DB_USER: beetask
DB_PASSWORD: beetask DB_PASSWORD: beetask
DB_URL: jdbc:postgresql://postgres:5432/beetask DB_URL: jdbc:postgresql://postgres:5432/beetask
QUARKUS_HTTP_CORS_ORIGINS: https://task2.beetech.dev QUARKUS_HTTP_CORS_ORIGINS: "https://task2.beetech.dev,https://task3.beetech.dev"
MP_JWT_VERIFY_ISSUER: ${MP_JWT_VERIFY_ISSUER} MP_JWT_VERIFY_ISSUER: ${MP_JWT_VERIFY_ISSUER}
SMALLRYE_JWT_SIGN_KEY: ${SMALLRYE_JWT_SIGN_KEY} SMALLRYE_JWT_SIGN_KEY: ${SMALLRYE_JWT_SIGN_KEY}
MP_JWT_VERIFY_PUBLICKEY: ${MP_JWT_VERIFY_PUBLICKEY} MP_JWT_VERIFY_PUBLICKEY: ${MP_JWT_VERIFY_PUBLICKEY}
@ -94,7 +94,7 @@ services:
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.docker.network=proxy" - "traefik.docker.network=proxy"
- "traefik.http.routers.backend.rule=Host(`api-task2.beetech.dev`)" - "traefik.http.routers.backend.rule=Host(`api-task2.beetech.dev`) || Host(`api-task3.beetech.dev`)"
- "traefik.http.routers.backend.entrypoints=web" - "traefik.http.routers.backend.entrypoints=web"
- "traefik.http.services.backend.loadbalancer.server.port=8081" - "traefik.http.services.backend.loadbalancer.server.port=8081"
@ -125,7 +125,7 @@ services:
context: ./new-frontend context: ./new-frontend
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
VITE_API_URL: ${VITE_API_URL:-https://api-task3.beetech.dev} VITE_API_URL: ${VITE_API_URL:-https://api-task2.beetech.dev}
container_name: bee-task-frontend2 container_name: bee-task-frontend2
restart: unless-stopped restart: unless-stopped
expose: expose:

View file

@ -15,7 +15,8 @@
"framer-motion": "^14.1.0", "framer-motion": "^14.1.0",
"lucide-react": "^1.55.0", "lucide-react": "^1.55.0",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8" "react-dom": "^19.2.8",
"react-router-dom": "^7.18.4"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
@ -1074,21 +1075,6 @@
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
} }
}, },
"node_modules/@react-spectrum/provider": {
"version": "3.11.1",
"resolved": "https://registry.npmjs.org/@react-spectrum/provider/-/provider-3.11.1.tgz",
"integrity": "sha512-TsoNdVdmlQ7L+75ILq5Yb3+wp/I1AtIeat0o+Y+ZBxP+TtWpwT1ZtCB5l3cplFVzHzOpZlzO0VaDrDP9ElGYDw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@adobe/react-spectrum": "^3.47.0",
"@swc/helpers": "^0.5.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-stately/color": { "node_modules/@react-stately/color": {
"version": "3.10.1", "version": "3.10.1",
"resolved": "https://registry.npmjs.org/@react-stately/color/-/color-3.10.1.tgz", "resolved": "https://registry.npmjs.org/@react-stately/color/-/color-3.10.1.tgz",
@ -2065,7 +2051,7 @@
"version": "19.3.0", "version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@ -2075,7 +2061,7 @@
"version": "19.3.0", "version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.3.0" "@types/react": "^19.3.0"
@ -2290,6 +2276,19 @@
"integrity": "sha512-OXAPGFRNeLFnUfqDtloYdxkwsJoIdXe28+bjbpJiPqyei2HPa3VHmMCWa0Qe62+U4Ftf9Hj7hRssOkxz7WiWbg==", "integrity": "sha512-OXAPGFRNeLFnUfqDtloYdxkwsJoIdXe28+bjbpJiPqyei2HPa3VHmMCWa0Qe62+U4Ftf9Hj7hRssOkxz7WiWbg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@ -3036,6 +3035,44 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-router": {
"version": "7.18.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
"integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.18.4",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz",
"integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.4"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-stately": { "node_modules/react-stately": {
"version": "3.51.0", "version": "3.51.0",
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.51.0.tgz", "resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.51.0.tgz",
@ -3118,6 +3155,12 @@
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/simple-swizzle": { "node_modules/simple-swizzle": {
"version": "0.2.4", "version": "0.2.4",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
@ -3173,6 +3216,7 @@
"version": "4.3.3", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/tapable": { "node_modules/tapable": {

View file

@ -17,7 +17,8 @@
"framer-motion": "^14.1.0", "framer-motion": "^14.1.0",
"lucide-react": "^1.55.0", "lucide-react": "^1.55.0",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8" "react-dom": "^19.2.8",
"react-router-dom": "^7.18.4"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",

View file

@ -1,30 +1,20 @@
import { Navbar } from "./components/landing/Navbar" import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { Hero } from "./components/landing/Hero" import { AuthProvider } from './contexts/AuthContext'
import { Stats } from "./components/landing/Stats" import { LandingPage } from './pages/LandingPage'
import { Features } from "./components/landing/Features" import { AuthPage } from './pages/AuthPage'
import { FeatureTabs } from "./components/landing/FeatureTabs"
import { Workflow } from "./components/landing/Workflow"
import { Testimonials } from "./components/landing/Testimonials"
import { Faq } from "./components/landing/Faq"
import { CtaSection } from "./components/landing/CtaSection"
import { Footer } from "./components/landing/Footer"
export function App() { export function App() {
return ( return (
<div className="min-h-screen bg-app-bg text-text-main font-sans selection:bg-brand-soft selection:text-brand flex flex-col"> <BrowserRouter>
<Navbar /> <AuthProvider>
<main className="flex-1"> <Routes>
<Hero /> <Route path="/" element={<LandingPage />} />
<Stats /> <Route path="/login" element={<AuthPage />} />
<Features /> <Route path="/register" element={<AuthPage />} />
<FeatureTabs /> <Route path="*" element={<Navigate to="/" replace />} />
<Workflow /> </Routes>
<Testimonials /> </AuthProvider>
<Faq /> </BrowserRouter>
<CtaSection />
</main>
<Footer />
</div>
) )
} }

View file

@ -1,15 +1,16 @@
import { useState, type FormEvent } from "react" import { useState, type FormEvent } from "react"
import { useNavigate } from "react-router-dom"
import { Button } from "@heroui/react" import { Button } from "@heroui/react"
import { ArrowRight, Sparkles, CheckCircle2, ShieldCheck } from "lucide-react" import { ArrowRight, Sparkles, ShieldCheck } from "lucide-react"
export const CtaSection = () => { export const CtaSection = () => {
const [email, setEmail] = useState("") const [email, setEmail] = useState("")
const [submitted, setSubmitted] = useState(false) const navigate = useNavigate()
const handleSubmit = (e: FormEvent) => { const handleSubmit = (e: FormEvent) => {
e.preventDefault() e.preventDefault()
if (email) { if (email) {
setSubmitted(true) navigate(`/register?mode=register&email=${encodeURIComponent(email)}`)
} }
} }
@ -36,17 +37,6 @@ export const CtaSection = () => {
organizar projetos e gerenciar seu squad com precisão cirúrgica. organizar projetos e gerenciar seu squad com precisão cirúrgica.
</p> </p>
{submitted ? (
<div className="bg-white/15 backdrop-blur-md border border-white/20 p-6 rounded-2xl max-w-md mx-auto space-y-2">
<div className="flex items-center justify-center gap-2 text-white font-bold text-lg">
<CheckCircle2 className="w-6 h-6 text-accent" />
Workspace em preparação!
</div>
<p className="text-xs text-white/80">
Enviamos o link de ativação para <strong>{email}</strong>.
</p>
</div>
) : (
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="flex flex-col sm:flex-row items-center justify-center gap-3 max-w-md mx-auto pt-2" className="flex flex-col sm:flex-row items-center justify-center gap-3 max-w-md mx-auto pt-2"
@ -67,7 +57,6 @@ export const CtaSection = () => {
<ArrowRight className="w-4 h-4" /> <ArrowRight className="w-4 h-4" />
</Button> </Button>
</form> </form>
)}
<div className="flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-xs text-white/70 pt-2 font-medium"> <div className="flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-xs text-white/70 pt-2 font-medium">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">

View file

@ -1,4 +1,5 @@
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { Link } from "react-router-dom"
import { import {
Button, Button,
Card, Card,
@ -80,12 +81,12 @@ export const Hero = () => {
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex flex-col sm:flex-row items-center justify-center lg:justify-start gap-4 pt-2"> <div className="flex flex-col sm:flex-row items-center justify-center lg:justify-start gap-4 pt-2">
<a href="#comecar" className="w-full sm:w-auto"> <Link to="/register" className="w-full sm:w-auto">
<Button className="w-full sm:w-auto bg-brand hover:bg-brand-strong text-white font-bold text-base px-7 py-3.5 rounded-xl shadow-xl shadow-brand/20 transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] flex items-center justify-center gap-2.5"> <Button className="w-full sm:w-auto bg-brand hover:bg-brand-strong text-white font-bold text-base px-7 py-3.5 rounded-xl shadow-xl shadow-brand/20 transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] flex items-center justify-center gap-2.5">
Criar Workspace Grátis Criar Workspace Grátis
<ArrowRight className="w-4 h-4" /> <ArrowRight className="w-4 h-4" />
</Button> </Button>
</a> </Link>
<a href="#preview" className="w-full sm:w-auto"> <a href="#preview" className="w-full sm:w-auto">
<Button className="w-full sm:w-auto bg-surface hover:bg-surface-muted text-text-main font-semibold text-base px-6 py-3.5 rounded-xl border border-border-soft shadow-xs transition-colors duration-150 flex items-center justify-center gap-2"> <Button className="w-full sm:w-auto bg-surface hover:bg-surface-muted text-text-main font-semibold text-base px-6 py-3.5 rounded-xl border border-border-soft shadow-xs transition-colors duration-150 flex items-center justify-center gap-2">
<Play className="w-4 h-4 text-accent fill-accent" /> <Play className="w-4 h-4 text-accent fill-accent" />

View file

@ -1,3 +1,4 @@
import { Link } from "react-router-dom"
import { Button, Chip, ChipLabel } from "@heroui/react" import { Button, Chip, ChipLabel } from "@heroui/react"
import { ArrowRight } from "lucide-react" import { ArrowRight } from "lucide-react"
@ -6,7 +7,7 @@ export const Navbar = () => {
<header className="sticky top-0 z-50 backdrop-blur-md bg-surface/85 border-b border-border-soft transition-all duration-200"> <header className="sticky top-0 z-50 backdrop-blur-md bg-surface/85 border-b border-border-soft transition-all duration-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-20 flex items-center justify-between"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-20 flex items-center justify-between">
{/* Brand */} {/* Brand */}
<div className="flex items-center gap-3"> <Link to="/" className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-accent to-brand flex items-center justify-center shadow-md shadow-accent/20"> <div className="w-10 h-10 rounded-xl bg-gradient-to-br from-accent to-brand flex items-center justify-center shadow-md shadow-accent/20">
<span className="text-xl select-none">🐝</span> <span className="text-xl select-none">🐝</span>
</div> </div>
@ -23,36 +24,36 @@ export const Navbar = () => {
by BeeTech Group by BeeTech Group
</p> </p>
</div> </div>
</div> </Link>
{/* Navigation Links */} {/* Navigation Links */}
<nav className="hidden md:flex items-center gap-8 text-sm font-medium text-text-muted"> <nav className="hidden md:flex items-center gap-8 text-sm font-medium text-text-muted">
<a <a
href="#features" href="/#features"
className="hover:text-brand transition-colors duration-150 flex items-center gap-1.5" className="hover:text-brand transition-colors duration-150 flex items-center gap-1.5"
> >
Funcionalidades Funcionalidades
</a> </a>
<a <a
href="#time-tracking" href="/#time-tracking"
className="hover:text-brand transition-colors duration-150" className="hover:text-brand transition-colors duration-150"
> >
Time Tracking Time Tracking
</a> </a>
<a <a
href="#workspaces" href="/#workspaces"
className="hover:text-brand transition-colors duration-150" className="hover:text-brand transition-colors duration-150"
> >
Workspaces Workspaces
</a> </a>
<a <a
href="#analytics" href="/#analytics"
className="hover:text-brand transition-colors duration-150" className="hover:text-brand transition-colors duration-150"
> >
Analytics & Gestão Analytics & Gestão
</a> </a>
<a <a
href="#faq" href="/#faq"
className="hover:text-brand transition-colors duration-150" className="hover:text-brand transition-colors duration-150"
> >
FAQ FAQ
@ -61,20 +62,20 @@ export const Navbar = () => {
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<a <Link
href="#login" to="/login"
className="text-sm font-semibold text-text-main hover:text-brand px-3 py-2 transition-colors" className="text-sm font-semibold text-text-main hover:text-brand px-3 py-2 transition-colors"
> >
Entrar Entrar
</a> </Link>
<a href="#comecar"> <Link to="/register">
<Button <Button
className="bg-brand hover:bg-brand-strong text-white font-semibold text-sm px-5 py-2.5 rounded-xl shadow-lg shadow-brand/25 transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] flex items-center gap-2" className="bg-brand hover:bg-brand-strong text-white font-semibold text-sm px-5 py-2.5 rounded-xl shadow-lg shadow-brand/25 transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] flex items-center gap-2"
> >
Começar Agora Começar Agora
<ArrowRight className="w-4 h-4" /> <ArrowRight className="w-4 h-4" />
</Button> </Button>
</a> </Link>
</div> </div>
</div> </div>
</header> </header>

View file

@ -0,0 +1,186 @@
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
useMemo,
type ReactNode,
} from 'react'
import {
authService,
type LoginResponse,
type CreateAccountResponse,
type OrganizationMembership,
} from '../services/authService'
interface AuthContextType {
user: LoginResponse | null
isAuthenticated: boolean
isLoading: boolean
activeOrg: OrganizationMembership | null
login: (email: string, password: string) => Promise<LoginResponse>
register: (name: string, email: string, password: string) => Promise<CreateAccountResponse>
logout: () => void
refreshUser: () => Promise<void>
setActiveOrg: (id: number) => void
}
const STORAGE_KEYS = {
USER: 'user',
ACTIVE_ORG_ID: 'activeOrgId',
} as const
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<LoginResponse | null>(null)
const [activeOrgId, setActiveOrgId] = useState<number | null>(null)
const [isLoading, setIsLoading] = useState(true)
const setupActiveOrg = useCallback((organizations: OrganizationMembership[]) => {
const storedOrgId = localStorage.getItem(STORAGE_KEYS.ACTIVE_ORG_ID)
const parsedStoredId = storedOrgId ? Number(storedOrgId) : null
const isValidOrg = organizations.some((org) => org.id === parsedStoredId)
if (isValidOrg && parsedStoredId !== null) {
setActiveOrgId(parsedStoredId)
} else if (organizations.length > 0) {
const firstOrgId = organizations[0].id
setActiveOrgId(firstOrgId)
localStorage.setItem(STORAGE_KEYS.ACTIVE_ORG_ID, firstOrgId.toString())
}
}, [])
const logout = useCallback(() => {
localStorage.removeItem(STORAGE_KEYS.USER)
localStorage.removeItem(STORAGE_KEYS.ACTIVE_ORG_ID)
setUser(null)
setActiveOrgId(null)
}, [])
const renewToken = useCallback(async (refreshToken: string) => {
try {
const response = await authService.refresh(refreshToken)
response.issuedAt = new Date().toISOString()
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(response))
setUser(response)
} catch {
logout()
}
}, [logout])
const checkTokenExpiration = useCallback(
(userData: LoginResponse) => {
const issuedAt = new Date(userData.issuedAt).getTime()
const expiresAt = issuedAt + userData.expiresIn * 1000
const now = Date.now()
if (now >= expiresAt) {
logout()
return false
}
// Se expirar em menos de 5 min, renova
if (expiresAt - now < 300000) {
renewToken(userData.refreshToken)
}
return true
},
[logout, renewToken]
)
useEffect(() => {
const storedUser = localStorage.getItem(STORAGE_KEYS.USER)
if (storedUser) {
try {
const userData: LoginResponse = JSON.parse(storedUser)
if (checkTokenExpiration(userData)) {
setUser(userData)
setupActiveOrg(userData.organizations)
}
} catch {
localStorage.removeItem(STORAGE_KEYS.USER)
}
}
setIsLoading(false)
}, [checkTokenExpiration, setupActiveOrg])
// Verificação periódica a cada 60 segundos
useEffect(() => {
if (!user) return
const interval = setInterval(() => {
checkTokenExpiration(user)
}, 60000)
return () => clearInterval(interval)
}, [user, checkTokenExpiration])
const login = async (email: string, password: string) => {
const response = await authService.login(email, password)
response.issuedAt = new Date().toISOString()
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(response))
setUser(response)
setupActiveOrg(response.organizations)
return response
}
const register = async (name: string, email: string, password: string) => {
return authService.register(name, email, password)
}
const refreshUser = async () => {
try {
const response = await authService.me()
const storedUser = localStorage.getItem(STORAGE_KEYS.USER)
if (storedUser) {
const userData = JSON.parse(storedUser)
const updatedUser = { ...userData, ...response }
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(updatedUser))
setUser(updatedUser)
setupActiveOrg(updatedUser.organizations)
}
} catch {
// Ignora erro
}
}
const setActiveOrg = (id: number) => {
setActiveOrgId(id)
localStorage.setItem(STORAGE_KEYS.ACTIVE_ORG_ID, id.toString())
}
const activeOrg = useMemo(
() => user?.organizations.find((org) => org.id === activeOrgId) || null,
[user, activeOrgId]
)
return (
<AuthContext.Provider
value={{
user,
isAuthenticated: !!user,
isLoading,
activeOrg,
login,
register,
logout,
refreshUser,
setActiveOrg,
}}
>
{children}
</AuthContext.Provider>
)
}
export const useAuth = () => {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}

View file

@ -0,0 +1,57 @@
const API_BASE_URL = (import.meta.env.VITE_API_URL as string | undefined) ?? 'http://localhost:8081'
function buildUrl(path: string) {
return `${API_BASE_URL}${path}`
}
type RequestOptions = Omit<RequestInit, 'body'> & {
body?: unknown
skipAuth?: boolean
}
export async function apiFetch<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { body, headers, skipAuth, ...rest } = options
let authHeader: Record<string, string> = {}
if (!skipAuth) {
const storedUser = localStorage.getItem('user')
if (storedUser) {
try {
const userData = JSON.parse(storedUser)
if (userData?.jwt) {
authHeader = { Authorization: `Bearer ${userData.jwt}` }
}
} catch {
// Ignora erro de parsing
}
}
}
const isFormData = body instanceof FormData
const defaultHeaders: Record<string, string> = isFormData ? {} : { 'Content-Type': 'application/json' }
const response = await fetch(buildUrl(path), {
...rest,
headers: {
...defaultHeaders,
...authHeader,
...((headers as Record<string, string>) || {}),
},
body: isFormData ? body : (body !== undefined && body !== null ? JSON.stringify(body) : undefined),
})
const text = await response.text()
if (!response.ok) {
let errorMessage = text || 'Não foi possível concluir a requisição.'
try {
const parsed = JSON.parse(text)
if (parsed.message) errorMessage = parsed.message
if (parsed.error) errorMessage = parsed.error
} catch {
// Usar texto puro
}
throw new Error(errorMessage)
}
return text ? (JSON.parse(text) as T) : (undefined as T)
}

View file

@ -0,0 +1,409 @@
import { useState, type FormEvent } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import {
Card,
CardHeader,
CardContent,
CardFooter,
Button,
Input,
Chip,
ChipLabel,
Alert,
AlertTitle,
AlertDescription,
} from '@heroui/react'
import {
Mail,
Lock,
User,
ArrowRight,
Eye,
EyeOff,
ArrowLeft,
ShieldCheck,
} from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
export const AuthPage = () => {
const [searchParams, setSearchParams] = useSearchParams()
const initialMode = searchParams.get('mode') === 'register' ? 'register' : 'login'
const initialEmail = searchParams.get('email') || ''
const [mode, setMode] = useState<'login' | 'register'>(initialMode)
// Form states
const [name, setName] = useState('')
const [email, setEmail] = useState(initialEmail)
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
// Status & UI
const [isLoading, setIsLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [successMessage, setSuccessMessage] = useState<string | null>(null)
const { login, register } = useAuth()
const navigate = useNavigate()
const switchMode = (newMode: 'login' | 'register') => {
setMode(newMode)
setSearchParams({ mode: newMode })
setErrorMessage(null)
setSuccessMessage(null)
}
const handleLogin = async (e: FormEvent) => {
e.preventDefault()
setErrorMessage(null)
setIsLoading(true)
try {
const response = await login(email, password)
if (!response.organizations || response.organizations.length === 0) {
navigate('/organizations')
} else {
navigate('/dashboard')
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Erro ao realizar login. Verifique suas credenciais.'
setErrorMessage(msg)
} finally {
setIsLoading(false)
}
}
const handleRegister = async (e: FormEvent) => {
e.preventDefault()
setErrorMessage(null)
if (password.length < 6) {
setErrorMessage('A senha deve conter no mínimo 6 caracteres.')
return
}
if (password !== confirmPassword) {
setErrorMessage('As senhas digitadas não coincidem.')
return
}
setIsLoading(true)
try {
await register(name, email, password)
setSuccessMessage('Conta criada com sucesso! Faça login para acessar seu workspace.')
setMode('login')
setSearchParams({ mode: 'login' })
setPassword('')
setConfirmPassword('')
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Erro ao criar conta. Tente novamente.'
setErrorMessage(msg)
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen bg-app-bg text-text-main flex flex-col justify-center items-center px-4 py-12 relative overflow-hidden font-sans">
{/* Decorative ambient gradients */}
<div className="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none -z-10">
<div className="absolute -top-32 -left-32 w-96 h-96 bg-accent/15 blur-[120px] rounded-full" />
<div className="absolute -bottom-32 -right-32 w-96 h-96 bg-brand/15 blur-[120px] rounded-full" />
</div>
{/* Top back navigation */}
<div className="w-full max-w-md mb-6 flex items-center justify-between">
<Link
to="/"
className="inline-flex items-center gap-2 text-xs font-semibold text-text-muted hover:text-brand transition-colors p-2 rounded-xl hover:bg-surface-muted"
>
<ArrowLeft className="w-4 h-4" />
Voltar para a página inicial
</Link>
<Chip className="bg-brand-soft text-brand text-xs font-semibold px-2 py-0.5 border border-brand/20">
<ChipLabel>v3.0</ChipLabel>
</Chip>
</div>
{/* Main Auth Card */}
<Card className="w-full max-w-md bg-surface border border-border-soft rounded-3xl p-6 sm:p-8 shadow-panel relative z-10 transition-all">
{/* Card Header & Brand */}
<CardHeader className="p-0 pb-6 flex flex-col items-center text-center space-y-3">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-accent to-brand flex items-center justify-center shadow-md shadow-accent/20">
<span className="text-2xl select-none">🐝</span>
</div>
<div>
<h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-text-main">
Bee<span className="text-accent">Task</span>
</h1>
<p className="text-xs sm:text-sm text-text-muted mt-1">
{mode === 'login'
? 'Acesse seu painel com segurança e foco'
: 'Crie seu workspace corporativo em segundos'}
</p>
</div>
{/* Mode Switcher Tabs */}
<div className="w-full grid grid-cols-2 p-1 bg-surface-muted rounded-xl border border-border-soft mt-3">
<button
type="button"
onClick={() => switchMode('login')}
className={`py-2 text-xs sm:text-sm font-bold rounded-lg transition-all ${
mode === 'login'
? 'bg-surface text-brand shadow-xs border border-border-soft'
: 'text-text-muted hover:text-text-main'
}`}
>
Entrar
</button>
<button
type="button"
onClick={() => switchMode('register')}
className={`py-2 text-xs sm:text-sm font-bold rounded-lg transition-all ${
mode === 'register'
? 'bg-surface text-brand shadow-xs border border-border-soft'
: 'text-text-muted hover:text-text-main'
}`}
>
Criar Conta
</button>
</div>
</CardHeader>
{/* Feedback Alerts */}
<CardContent className="p-0 space-y-5">
{errorMessage && (
<Alert className="bg-danger-soft border border-danger/30 text-danger rounded-xl p-3 text-xs flex items-start gap-2">
<div className="flex-1">
<AlertTitle className="font-bold">Atenção</AlertTitle>
<AlertDescription className="text-xs mt-0.5">{errorMessage}</AlertDescription>
</div>
</Alert>
)}
{successMessage && (
<Alert className="bg-success-soft border border-success/30 text-success rounded-xl p-3 text-xs flex items-start gap-2">
<div className="flex-1">
<AlertTitle className="font-bold">Sucesso</AlertTitle>
<AlertDescription className="text-xs mt-0.5">{successMessage}</AlertDescription>
</div>
</Alert>
)}
{/* Mode Form */}
{mode === 'login' ? (
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-1.5">
<label className="text-xs font-bold text-text-main">
E-mail Profissional
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-text-muted">
<Mail className="w-4 h-4" />
</div>
<Input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="seu.email@empresa.com"
className="w-full pl-10 pr-4 py-2.5 bg-surface-muted/60 border border-border-soft rounded-xl text-sm text-text-main placeholder-text-muted/60 focus:outline-none focus:ring-2 focus:ring-brand/20 focus:border-brand transition-all"
/>
</div>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-xs font-bold text-text-main">
Senha de Acesso
</label>
<a
href="#esqueci"
onClick={(e) => {
e.preventDefault()
alert('Entre em contato com o administrador do seu Workspace para redefinir sua senha.')
}}
className="text-[11px] font-semibold text-brand hover:underline"
>
Esqueceu?
</a>
</div>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-text-muted">
<Lock className="w-4 h-4" />
</div>
<Input
type={showPassword ? 'text' : 'password'}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="w-full pl-10 pr-10 py-2.5 bg-surface-muted/60 border border-border-soft rounded-xl text-sm text-text-main placeholder-text-muted/60 focus:outline-none focus:ring-2 focus:ring-brand/20 focus:border-brand transition-all font-mono"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-3.5 flex items-center text-text-muted hover:text-text-main transition-colors"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<Button
type="submit"
isDisabled={isLoading}
className="w-full bg-brand hover:bg-brand-strong text-white font-bold text-sm py-3 rounded-xl shadow-lg shadow-brand/20 transition-all duration-200 flex items-center justify-center gap-2 mt-2"
>
{isLoading ? (
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
Acessar Workspace
<ArrowRight className="w-4 h-4" />
</>
)}
</Button>
</form>
) : (
<form onSubmit={handleRegister} className="space-y-4">
<div className="space-y-1.5">
<label className="text-xs font-bold text-text-main">
Nome Completo
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-text-muted">
<User className="w-4 h-4" />
</div>
<Input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ex: Carlos Oliveira"
className="w-full pl-10 pr-4 py-2.5 bg-surface-muted/60 border border-border-soft rounded-xl text-sm text-text-main placeholder-text-muted/60 focus:outline-none focus:ring-2 focus:ring-brand/20 focus:border-brand transition-all"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-text-main">
E-mail Corporativo
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-text-muted">
<Mail className="w-4 h-4" />
</div>
<Input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="seu.email@empresa.com"
className="w-full pl-10 pr-4 py-2.5 bg-surface-muted/60 border border-border-soft rounded-xl text-sm text-text-main placeholder-text-muted/60 focus:outline-none focus:ring-2 focus:ring-brand/20 focus:border-brand transition-all"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-text-main">
Crie uma Senha
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-text-muted">
<Lock className="w-4 h-4" />
</div>
<Input
type={showPassword ? 'text' : 'password'}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Mínimo 6 dígitos"
className="w-full pl-10 pr-10 py-2.5 bg-surface-muted/60 border border-border-soft rounded-xl text-sm text-text-main placeholder-text-muted/60 focus:outline-none focus:ring-2 focus:ring-brand/20 focus:border-brand transition-all font-mono"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-3.5 flex items-center text-text-muted hover:text-text-main transition-colors"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-text-main">
Confirme sua Senha
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-text-muted">
<Lock className="w-4 h-4" />
</div>
<Input
type={showPassword ? 'text' : 'password'}
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Repita a senha"
className="w-full pl-10 pr-4 py-2.5 bg-surface-muted/60 border border-border-soft rounded-xl text-sm text-text-main placeholder-text-muted/60 focus:outline-none focus:ring-2 focus:ring-brand/20 focus:border-brand transition-all font-mono"
/>
</div>
</div>
<Button
type="submit"
isDisabled={isLoading}
className="w-full bg-accent hover:bg-[#b45309] text-white font-bold text-sm py-3 rounded-xl shadow-lg shadow-accent/20 transition-all duration-200 flex items-center justify-center gap-2 mt-2"
>
{isLoading ? (
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
Criar Minha Conta
<ArrowRight className="w-4 h-4" />
</>
)}
</Button>
</form>
)}
</CardContent>
{/* Footer Links & Info */}
<CardFooter className="p-0 pt-6 mt-6 border-t border-border-soft flex flex-col items-center space-y-3 text-center">
<p className="text-xs text-text-muted">
{mode === 'login' ? (
<>
Ainda não tem conta no Bee Task?{' '}
<button
type="button"
onClick={() => switchMode('register')}
className="font-bold text-brand hover:underline"
>
Cadastre-se grátis
</button>
</>
) : (
<>
Já possui uma conta ativa?{' '}
<button
type="button"
onClick={() => switchMode('login')}
className="font-bold text-brand hover:underline"
>
Fazer login
</button>
</>
)}
</p>
<div className="flex items-center justify-center gap-2 text-[11px] text-text-muted font-medium pt-1">
<ShieldCheck className="w-3.5 h-3.5 text-brand" />
<span>Autenticação segura com JWT Quarkus & MinIO S3</span>
</div>
</CardFooter>
</Card>
</div>
)
}

View file

@ -0,0 +1,29 @@
import { Navbar } from "../components/landing/Navbar"
import { Hero } from "../components/landing/Hero"
import { Stats } from "../components/landing/Stats"
import { Features } from "../components/landing/Features"
import { FeatureTabs } from "../components/landing/FeatureTabs"
import { Workflow } from "../components/landing/Workflow"
import { Testimonials } from "../components/landing/Testimonials"
import { Faq } from "../components/landing/Faq"
import { CtaSection } from "../components/landing/CtaSection"
import { Footer } from "../components/landing/Footer"
export const LandingPage = () => {
return (
<div className="min-h-screen bg-app-bg text-text-main font-sans selection:bg-brand-soft selection:text-brand flex flex-col">
<Navbar />
<main className="flex-1">
<Hero />
<Stats />
<Features />
<FeatureTabs />
<Workflow />
<Testimonials />
<Faq />
<CtaSection />
</main>
<Footer />
</div>
)
}

View file

@ -0,0 +1,54 @@
import { apiFetch } from '../lib/api'
export type OrganizationMembership = {
id: number
name: string
roles: string[]
}
export type LoginResponse = {
name: string
email: string
photo: string
jwt: string
refreshToken: string
expiresIn: number // seconds
issuedAt: string // ISO date
organizations: OrganizationMembership[]
}
export type CreateAccountResponse = {
id: number
name: string
email: string
}
export const authService = {
async login(email: string, password: string): Promise<LoginResponse> {
return apiFetch<LoginResponse>('/auth/login', {
method: 'POST',
body: { email, password },
skipAuth: true,
})
},
async register(name: string, email: string, password: string): Promise<CreateAccountResponse> {
return apiFetch<CreateAccountResponse>('/auth/register', {
method: 'POST',
body: { name, email, password },
skipAuth: true,
})
},
async refresh(refreshToken: string): Promise<LoginResponse> {
return apiFetch<LoginResponse>('/auth/refresh', {
method: 'POST',
body: { refreshToken },
skipAuth: true,
})
},
async me(): Promise<LoginResponse> {
return apiFetch<LoginResponse>('/auth/me')
},
}