feat: add auth page with login/register and enable task3 CORS origin
This commit is contained in:
parent
727cf62f7d
commit
8015b3ff77
13 changed files with 856 additions and 95 deletions
|
|
@ -1,7 +1,7 @@
|
|||
quarkus.http.port=8081
|
||||
|
||||
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.exposed-headers=Content-Disposition
|
||||
quarkus.http.cors.access-control-max-age=24H
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ services:
|
|||
DB_USER: beetask
|
||||
DB_PASSWORD: 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}
|
||||
SMALLRYE_JWT_SIGN_KEY: ${SMALLRYE_JWT_SIGN_KEY}
|
||||
MP_JWT_VERIFY_PUBLICKEY: ${MP_JWT_VERIFY_PUBLICKEY}
|
||||
|
|
@ -94,7 +94,7 @@ services:
|
|||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "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.services.backend.loadbalancer.server.port=8081"
|
||||
|
||||
|
|
@ -125,7 +125,7 @@ services:
|
|||
context: ./new-frontend
|
||||
dockerfile: Dockerfile
|
||||
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
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
|
|
|
|||
80
new-frontend/package-lock.json
generated
80
new-frontend/package-lock.json
generated
|
|
@ -15,7 +15,8 @@
|
|||
"framer-motion": "^14.1.0",
|
||||
"lucide-react": "^1.55.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-stately/color/-/color-3.10.1.tgz",
|
||||
|
|
@ -2065,7 +2051,7 @@
|
|||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
|
||||
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
|
|
@ -2075,7 +2061,7 @@
|
|||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
|
||||
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.3.0"
|
||||
|
|
@ -2290,6 +2276,19 @@
|
|||
"integrity": "sha512-OXAPGFRNeLFnUfqDtloYdxkwsJoIdXe28+bjbpJiPqyei2HPa3VHmMCWa0Qe62+U4Ftf9Hj7hRssOkxz7WiWbg==",
|
||||
"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": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
|
|
@ -3036,6 +3035,44 @@
|
|||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"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": {
|
||||
"version": "3.51.0",
|
||||
"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==",
|
||||
"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": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
|
||||
|
|
@ -3173,6 +3216,7 @@
|
|||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
|
||||
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@
|
|||
"framer-motion": "^14.1.0",
|
||||
"lucide-react": "^1.55.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
|
|
|
|||
|
|
@ -1,30 +1,20 @@
|
|||
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"
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import { LandingPage } from './pages/LandingPage'
|
||||
import { AuthPage } from './pages/AuthPage'
|
||||
|
||||
export function App() {
|
||||
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>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/login" element={<AuthPage />} />
|
||||
<Route path="/register" element={<AuthPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
import { useState, type FormEvent } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { Button } from "@heroui/react"
|
||||
import { ArrowRight, Sparkles, CheckCircle2, ShieldCheck } from "lucide-react"
|
||||
import { ArrowRight, Sparkles, ShieldCheck } from "lucide-react"
|
||||
|
||||
export const CtaSection = () => {
|
||||
const [email, setEmail] = useState("")
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (email) {
|
||||
setSubmitted(true)
|
||||
navigate(`/register?mode=register&email=${encodeURIComponent(email)}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -36,38 +37,26 @@ export const CtaSection = () => {
|
|||
organizar projetos e gerenciar seu squad com precisão cirúrgica.
|
||||
</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
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col sm:flex-row items-center justify-center gap-3 max-w-md mx-auto pt-2"
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col sm:flex-row items-center justify-center gap-3 max-w-md mx-auto pt-2"
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
placeholder="Seu melhor e-mail profissional"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full sm:w-80 px-4 py-3 rounded-xl bg-white/10 backdrop-blur-md border border-white/20 text-white placeholder-white/60 focus:outline-none focus:ring-2 focus:ring-accent text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full sm:w-auto bg-accent hover:bg-[#b45309] text-white font-bold text-sm px-6 py-3 rounded-xl shadow-lg shadow-black/20 flex items-center justify-center gap-2 transition-all duration-200"
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
placeholder="Seu melhor e-mail profissional"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full sm:w-80 px-4 py-3 rounded-xl bg-white/10 backdrop-blur-md border border-white/20 text-white placeholder-white/60 focus:outline-none focus:ring-2 focus:ring-accent text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full sm:w-auto bg-accent hover:bg-[#b45309] text-white font-bold text-sm px-6 py-3 rounded-xl shadow-lg shadow-black/20 flex items-center justify-center gap-2 transition-all duration-200"
|
||||
>
|
||||
Criar Conta
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
Criar Conta
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</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">
|
||||
<span className="flex items-center gap-1.5">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
|
|
@ -80,12 +81,12 @@ export const Hero = () => {
|
|||
|
||||
{/* Action Buttons */}
|
||||
<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">
|
||||
Criar Workspace Grátis
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
<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">
|
||||
<Play className="w-4 h-4 text-accent fill-accent" />
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Link } from "react-router-dom"
|
||||
import { Button, Chip, ChipLabel } from "@heroui/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">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-20 flex items-center justify-between">
|
||||
{/* 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">
|
||||
<span className="text-xl select-none">🐝</span>
|
||||
</div>
|
||||
|
|
@ -23,36 +24,36 @@ export const Navbar = () => {
|
|||
by BeeTech Group
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Navigation Links */}
|
||||
<nav className="hidden md:flex items-center gap-8 text-sm font-medium text-text-muted">
|
||||
<a
|
||||
href="#features"
|
||||
href="/#features"
|
||||
className="hover:text-brand transition-colors duration-150 flex items-center gap-1.5"
|
||||
>
|
||||
Funcionalidades
|
||||
</a>
|
||||
<a
|
||||
href="#time-tracking"
|
||||
href="/#time-tracking"
|
||||
className="hover:text-brand transition-colors duration-150"
|
||||
>
|
||||
Time Tracking
|
||||
</a>
|
||||
<a
|
||||
href="#workspaces"
|
||||
href="/#workspaces"
|
||||
className="hover:text-brand transition-colors duration-150"
|
||||
>
|
||||
Workspaces
|
||||
</a>
|
||||
<a
|
||||
href="#analytics"
|
||||
href="/#analytics"
|
||||
className="hover:text-brand transition-colors duration-150"
|
||||
>
|
||||
Analytics & Gestão
|
||||
</a>
|
||||
<a
|
||||
href="#faq"
|
||||
href="/#faq"
|
||||
className="hover:text-brand transition-colors duration-150"
|
||||
>
|
||||
FAQ
|
||||
|
|
@ -61,20 +62,20 @@ export const Navbar = () => {
|
|||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="#login"
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-sm font-semibold text-text-main hover:text-brand px-3 py-2 transition-colors"
|
||||
>
|
||||
Entrar
|
||||
</a>
|
||||
<a href="#comecar">
|
||||
</Link>
|
||||
<Link to="/register">
|
||||
<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"
|
||||
>
|
||||
Começar Agora
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
|||
186
new-frontend/src/contexts/AuthContext.tsx
Normal file
186
new-frontend/src/contexts/AuthContext.tsx
Normal 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
|
||||
}
|
||||
57
new-frontend/src/lib/api.ts
Normal file
57
new-frontend/src/lib/api.ts
Normal 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)
|
||||
}
|
||||
409
new-frontend/src/pages/AuthPage.tsx
Normal file
409
new-frontend/src/pages/AuthPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
new-frontend/src/pages/LandingPage.tsx
Normal file
29
new-frontend/src/pages/LandingPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
54
new-frontend/src/services/authService.ts
Normal file
54
new-frontend/src/services/authService.ts
Normal 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')
|
||||
},
|
||||
}
|
||||
Loading…
Reference in a new issue