feat: basic initial UI layout for mobile and desktop
This commit is contained in:
26
apps/web/src/components/auth/AuthShell.tsx
Normal file
26
apps/web/src/components/auth/AuthShell.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Logo, LogoIcon } from '../brand'
|
||||
|
||||
export type AuthShellProps = {
|
||||
title: string
|
||||
subtitle?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function AuthShell({ title, subtitle, children }: AuthShellProps) {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center bg-background px-4 py-8">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-6 flex items-center justify-center gap-2">
|
||||
<LogoIcon className="size-9" />
|
||||
<Logo className="text-xl" />
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-white p-6 shadow-sm">
|
||||
<h1 className="text-xl font-bold text-primary">{title}</h1>
|
||||
{subtitle ? <p className="mt-1 text-sm text-foreground/60">{subtitle}</p> : null}
|
||||
<div className="mt-5">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1
apps/web/src/components/auth/index.ts
Normal file
1
apps/web/src/components/auth/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AuthShell } from './AuthShell'
|
||||
62
apps/web/src/components/layout/Breadcrumb.tsx
Normal file
62
apps/web/src/components/layout/Breadcrumb.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Link, useLocation } from '@tanstack/react-router'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
|
||||
type Crumb = { label: string; to?: string }
|
||||
|
||||
const BREADCRUMBS: Record<string, Crumb[]> = {
|
||||
'/': [{ label: 'Inicio' }],
|
||||
'/groups': [
|
||||
{ label: 'Inicio', to: '/' },
|
||||
{ label: 'Grupos' },
|
||||
],
|
||||
'/payments': [
|
||||
{ label: 'Inicio', to: '/' },
|
||||
{ label: 'Cobros' },
|
||||
],
|
||||
'/settings': [
|
||||
{ label: 'Inicio', to: '/' },
|
||||
{ label: 'Ajustes' },
|
||||
],
|
||||
'/seguridad': [
|
||||
{ label: 'Inicio', to: '/' },
|
||||
{ label: 'Ajustes', to: '/settings' },
|
||||
{ label: 'Seguridad' },
|
||||
],
|
||||
'/settings/organizations': [
|
||||
{ label: 'Inicio', to: '/' },
|
||||
{ label: 'Ajustes', to: '/settings' },
|
||||
{ label: 'Organizaciones' },
|
||||
],
|
||||
'/profile': [
|
||||
{ label: 'Inicio', to: '/' },
|
||||
{ label: 'Mi perfil' },
|
||||
],
|
||||
}
|
||||
|
||||
export function Breadcrumb() {
|
||||
const location = useLocation()
|
||||
const crumbs = BREADCRUMBS[location.pathname] ?? [{ label: 'Inicio' }]
|
||||
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className="hidden items-center gap-1.5 text-sm lg:flex">
|
||||
{crumbs.map((crumb, i) => {
|
||||
const isLast = i === crumbs.length - 1
|
||||
return (
|
||||
<span key={crumb.label} className="flex items-center gap-1.5">
|
||||
{i > 0 ? <ChevronRight className="size-3.5 text-foreground/40" /> : null}
|
||||
{isLast ? (
|
||||
<span className="font-medium text-primary">{crumb.label}</span>
|
||||
) : (
|
||||
<Link
|
||||
to={crumb.to!}
|
||||
className="text-foreground/50 transition-colors hover:text-primary"
|
||||
>
|
||||
{crumb.label}
|
||||
</Link>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
20
apps/web/src/components/layout/Header.tsx
Normal file
20
apps/web/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Logo, LogoIcon } from '../brand'
|
||||
import { Breadcrumb } from './Breadcrumb'
|
||||
import { UserMenu } from './UserMenu'
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<header className="sticky top-0 z-40 h-16 w-full border-b border-border bg-background/90 backdrop-blur">
|
||||
<div className="mx-auto flex h-full w-full max-w-7xl items-center justify-between px-4 lg:px-6">
|
||||
<div className="flex min-w-0 items-center">
|
||||
<a href="/" className="flex items-center gap-2 lg:hidden" aria-label="Gruperly inicio">
|
||||
<LogoIcon className="size-7" />
|
||||
<Logo className="text-lg tracking-tight" />
|
||||
</a>
|
||||
<Breadcrumb />
|
||||
</div>
|
||||
<UserMenu />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -1,44 +1,22 @@
|
||||
import { Outlet } from '@tanstack/react-router'
|
||||
import { Bell } from 'lucide-react'
|
||||
import { Logo, LogoIcon } from '../brand'
|
||||
import { Avatar, Button } from '../ui'
|
||||
import { Header } from './Header'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { BottomNav } from './BottomNav'
|
||||
|
||||
export function RootLayout() {
|
||||
return (
|
||||
<div className="mx-auto flex min-h-dvh w-full max-w-6xl gap-6 lg:px-6">
|
||||
<Sidebar />
|
||||
<div className="min-h-dvh w-full">
|
||||
<Header />
|
||||
|
||||
<div className="flex min-h-dvh w-full flex-col lg:min-w-0">
|
||||
<header className="sticky top-0 z-30 border-b border-border bg-background/90 backdrop-blur">
|
||||
<div className="mx-auto flex h-14 w-full max-w-md items-center justify-between px-4 lg:max-w-none lg:px-0">
|
||||
<a
|
||||
href="/"
|
||||
className="flex items-center gap-2 lg:hidden"
|
||||
aria-label="Gruperly inicio"
|
||||
>
|
||||
<LogoIcon className="size-8" />
|
||||
<Logo className="text-lg tracking-tight" />
|
||||
</a>
|
||||
<div className="ml-auto flex items-center gap-1 lg:ml-0">
|
||||
<Button size="icon" variant="ghost" aria-label="Notificaciones" className="relative">
|
||||
<Bell className="size-5" />
|
||||
<span className="absolute right-1.5 top-1.5 size-2 rounded-full bg-accent" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" aria-label="Perfil">
|
||||
<Avatar name="Gruperly User" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="mx-auto flex w-full max-w-7xl gap-6 lg:px-6">
|
||||
<Sidebar />
|
||||
|
||||
<main className="mx-auto w-full max-w-md flex-1 px-4 pb-24 pt-4 lg:max-w-none lg:px-0 lg:pb-8 lg:pt-6">
|
||||
<main className="min-w-0 flex-1 px-4 pb-24 pt-4 lg:px-0 lg:pb-8 lg:pt-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<BottomNav />
|
||||
</div>
|
||||
|
||||
<BottomNav />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export function Sidebar() {
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<aside className="sticky top-0 hidden h-dvh w-60 shrink-0 flex-col border-r border-border bg-white px-4 py-6 lg:flex">
|
||||
<aside className="sticky top-16 hidden h-[calc(100dvh-4rem)] w-64 shrink-0 flex-col border-r border-border bg-white px-4 py-6 lg:flex">
|
||||
<a href="/" className="mb-8 flex items-center gap-2 px-1" aria-label="Gruperly inicio">
|
||||
<LogoIcon className="size-8" />
|
||||
<Logo className="text-lg tracking-tight" />
|
||||
|
||||
174
apps/web/src/components/layout/UserMenu.tsx
Normal file
174
apps/web/src/components/layout/UserMenu.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { ChevronDown, LogOut, Settings, UserRound, X } from 'lucide-react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { signOut } from '../../lib/auth-client'
|
||||
import { useAuth } from '../../context/AuthProvider'
|
||||
import { useIsMobile } from '../../hooks/useIsMobile'
|
||||
import { Avatar } from '../ui'
|
||||
|
||||
const itemClass =
|
||||
'flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm font-medium text-primary transition-colors hover:bg-primary-soft'
|
||||
|
||||
export function UserMenu() {
|
||||
const { user } = useAuth()
|
||||
const isMobile = useIsMobile()
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || isMobile) return
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('pointerdown', handlePointerDown)
|
||||
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
||||
}, [open, isMobile])
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile && open) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}
|
||||
}, [isMobile, open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [open])
|
||||
|
||||
const handleSignOut = async () => {
|
||||
setOpen(false)
|
||||
await signOut({ fetchOptions: { headers: { 'Cache-Control': 'no-cache' } } })
|
||||
navigate({ to: '/login' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
className="flex items-center gap-2 rounded-xl p-1 transition-colors hover:bg-primary-soft"
|
||||
>
|
||||
<Avatar name={user?.name} src={user?.image ?? undefined} className="size-8 lg:size-9" />
|
||||
<span className="hidden max-w-40 flex-col items-start leading-tight lg:flex">
|
||||
<span className="truncate text-sm font-medium text-primary">{user?.name ?? 'Usuario'}</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'hidden size-4 text-foreground/50 transition-transform lg:block',
|
||||
open && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isMobile
|
||||
? open
|
||||
? createPortal(
|
||||
<div role="dialog" aria-modal="true" className="fixed inset-0 z-50 lg:hidden">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 animate-fade-in bg-black/20 backdrop-blur-sm"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<div className="absolute inset-0 flex animate-fade-in flex-col overflow-y-auto bg-white/90 backdrop-blur">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Cerrar menú"
|
||||
className="absolute right-4 top-4 z-20 rounded-full bg-white/90 p-2 text-foreground/70 shadow-md backdrop-blur transition-colors hover:bg-white"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
<div className="flex min-h-full flex-col items-center justify-center gap-8 px-6 py-12">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<Avatar
|
||||
name={user?.name}
|
||||
src={user?.image ?? undefined}
|
||||
className="size-16 text-xl"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-base font-semibold text-primary">
|
||||
{user?.name ?? 'Usuario'}
|
||||
</p>
|
||||
<p className="text-sm text-foreground/50">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-1.5 rounded-2xl border border-border/60 bg-white/90 p-2 shadow-xl backdrop-blur">
|
||||
<Link
|
||||
role="menuitem"
|
||||
to="/profile"
|
||||
onClick={() => setOpen(false)}
|
||||
className={cn(itemClass, 'py-3.5')}
|
||||
>
|
||||
<UserRound className="size-5 text-foreground/60" />
|
||||
Mi perfil
|
||||
</Link>
|
||||
<Link
|
||||
role="menuitem"
|
||||
to="/settings"
|
||||
onClick={() => setOpen(false)}
|
||||
className={cn(itemClass, 'py-3.5')}
|
||||
>
|
||||
<Settings className="size-5 text-foreground/60" />
|
||||
Ajustes
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={handleSignOut}
|
||||
className={cn(
|
||||
itemClass,
|
||||
'w-full justify-center border border-border bg-white/90 py-3.5 shadow-sm backdrop-blur hover:bg-primary-soft',
|
||||
)}
|
||||
>
|
||||
<LogOut className="size-5 text-foreground/60" />
|
||||
Cerrar sesión
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null
|
||||
: open ? (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 top-full z-50 mt-2 w-60 rounded-xl border border-border bg-white p-1.5 shadow-lg"
|
||||
>
|
||||
<div className="px-3 py-2">
|
||||
<p className="truncate text-sm font-semibold text-primary">{user?.name ?? 'Usuario'}</p>
|
||||
<p className="truncate text-xs text-foreground/50">{user?.email}</p>
|
||||
</div>
|
||||
<div className="mx-3 my-1 h-px bg-border" />
|
||||
<Link role="menuitem" to="/profile" onClick={() => setOpen(false)} className={itemClass}>
|
||||
<UserRound className="size-4 text-foreground/60" />
|
||||
Mi perfil
|
||||
</Link>
|
||||
<Link role="menuitem" to="/settings" onClick={() => setOpen(false)} className={itemClass}>
|
||||
<Settings className="size-4 text-foreground/60" />
|
||||
Ajustes
|
||||
</Link>
|
||||
<div className="mx-3 my-1 h-px bg-border" />
|
||||
<button type="button" role="menuitem" onClick={handleSignOut} className={itemClass}>
|
||||
<LogOut className="size-4 text-foreground/60" />
|
||||
Cerrar sesión
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { RootLayout } from './RootLayout'
|
||||
export { Header } from './Header'
|
||||
export { Sidebar } from './Sidebar'
|
||||
export { BottomNav } from './BottomNav'
|
||||
export { NAV_ITEMS, type NavItem } from './nav-items'
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export { Avatar, type AvatarProps } from './avatar'
|
||||
export { Button, type ButtonProps, type ButtonVariant, type ButtonSize } from './button'
|
||||
export { Badge, type BadgeProps, type BadgeVariant } from './badge'
|
||||
export { Input, type InputProps } from './input'
|
||||
export { Label, type LabelProps } from './label'
|
||||
|
||||
24
apps/web/src/components/ui/input.tsx
Normal file
24
apps/web/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { forwardRef, type InputHTMLAttributes } from 'react'
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
export type InputProps = InputHTMLAttributes<HTMLInputElement> & {
|
||||
invalid?: boolean
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, invalid, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-10 w-full rounded-xl border border-border bg-white px-3 text-sm text-primary placeholder:text-foreground/40 transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
invalid && 'border-danger focus:border-danger focus:ring-danger/30',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
Input.displayName = 'Input'
|
||||
13
apps/web/src/components/ui/label.tsx
Normal file
13
apps/web/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { LabelHTMLAttributes } from 'react'
|
||||
import { cn } from '../../lib/utils'
|
||||
|
||||
export type LabelProps = LabelHTMLAttributes<HTMLLabelElement>
|
||||
|
||||
export function Label({ className, ...props }: LabelProps) {
|
||||
return (
|
||||
<label
|
||||
className={cn('mb-1.5 block text-sm font-medium text-primary', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
36
apps/web/src/context/AuthProvider.tsx
Normal file
36
apps/web/src/context/AuthProvider.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
import { authClient } from '../lib/auth-client'
|
||||
|
||||
type AuthSession = {
|
||||
user: (typeof authClient.$Infer.Session)['user']
|
||||
session: (typeof authClient.$Infer.Session)['session']
|
||||
}
|
||||
|
||||
type AuthContextValue = {
|
||||
user: AuthSession['user'] | null
|
||||
session: AuthSession['session'] | null
|
||||
isPending: boolean
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const { data, isPending } = authClient.useSession()
|
||||
|
||||
const refresh = async () => {
|
||||
await authClient.getSession()
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user: data?.user ?? null, session: data?.session ?? null, isPending, refresh }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) throw new Error('useAuth debe usarse dentro de <AuthProvider>')
|
||||
return ctx
|
||||
}
|
||||
19
apps/web/src/hooks/useIsMobile.ts
Normal file
19
apps/web/src/hooks/useIsMobile.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const MOBILE_QUERY = '(max-width: 1023px)'
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = useState(
|
||||
() => typeof window !== 'undefined' && window.matchMedia(MOBILE_QUERY).matches,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(MOBILE_QUERY)
|
||||
const handleChange = (event: MediaQueryListEvent) => setIsMobile(event.matches)
|
||||
setIsMobile(mql.matches)
|
||||
mql.addEventListener('change', handleChange)
|
||||
return () => mql.removeEventListener('change', handleChange)
|
||||
}, [])
|
||||
|
||||
return isMobile
|
||||
}
|
||||
@@ -21,6 +21,18 @@
|
||||
|
||||
/* Border radius por defecto */
|
||||
--radius-xl: 0.75rem;
|
||||
|
||||
/* Animaciones */
|
||||
--animate-fade-in: fade-in 0.2s ease-out;
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
18
apps/web/src/lib/auth-client.ts
Normal file
18
apps/web/src/lib/auth-client.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createAuthClient } from 'better-auth/react'
|
||||
import { organizationClient } from 'better-auth/client/plugins'
|
||||
import { passkeyClient } from '@better-auth/passkey/client'
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: import.meta.env.VITE_API_URL ?? 'http://localhost:4000',
|
||||
plugins: [organizationClient(), passkeyClient()],
|
||||
})
|
||||
|
||||
export const {
|
||||
signIn,
|
||||
signUp,
|
||||
signOut,
|
||||
useSession,
|
||||
getSession,
|
||||
passkey,
|
||||
organization,
|
||||
} = authClient
|
||||
@@ -2,10 +2,13 @@ import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { RouterProvider } from '@tanstack/react-router'
|
||||
import { router } from './router'
|
||||
import { AuthProvider } from './context/AuthProvider'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
)
|
||||
@@ -1,43 +1,100 @@
|
||||
import { createRootRoute, createRoute, createRouter } from '@tanstack/react-router'
|
||||
import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router'
|
||||
import { RootLayout } from './components/layout'
|
||||
import { HomeView } from './routes/home'
|
||||
import { GroupsView } from './routes/groups'
|
||||
import { PaymentsView } from './routes/payments'
|
||||
import { SettingsView } from './routes/settings'
|
||||
import { ProfileView } from './routes/profile'
|
||||
import { SecurityPage } from './routes/security'
|
||||
import { OrganizationsPage } from './routes/organizations'
|
||||
import { LoginPage } from './routes/auth/login'
|
||||
import { SignupPage } from './routes/auth/signup'
|
||||
import { VerifyEmailPage } from './routes/auth/verify-email'
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => <Outlet />,
|
||||
})
|
||||
|
||||
const loginRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/login',
|
||||
component: LoginPage,
|
||||
})
|
||||
|
||||
const signupRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/signup',
|
||||
component: SignupPage,
|
||||
})
|
||||
|
||||
const verifyEmailRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/verify-email',
|
||||
component: VerifyEmailPage,
|
||||
})
|
||||
|
||||
// Capa con la navegación de la app autenticada (Sidebar + BottomNav).
|
||||
const appLayoutRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
id: 'app',
|
||||
component: RootLayout,
|
||||
})
|
||||
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
getParentRoute: () => appLayoutRoute,
|
||||
path: '/',
|
||||
component: HomeView,
|
||||
})
|
||||
|
||||
const groupsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
getParentRoute: () => appLayoutRoute,
|
||||
path: '/groups',
|
||||
component: GroupsView,
|
||||
})
|
||||
|
||||
const paymentsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
getParentRoute: () => appLayoutRoute,
|
||||
path: '/payments',
|
||||
component: PaymentsView,
|
||||
})
|
||||
|
||||
const settingsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
getParentRoute: () => appLayoutRoute,
|
||||
path: '/settings',
|
||||
component: SettingsView,
|
||||
})
|
||||
|
||||
const securityRoute = createRoute({
|
||||
getParentRoute: () => appLayoutRoute,
|
||||
path: '/seguridad',
|
||||
component: SecurityPage,
|
||||
})
|
||||
|
||||
const profileRoute = createRoute({
|
||||
getParentRoute: () => appLayoutRoute,
|
||||
path: '/profile',
|
||||
component: ProfileView,
|
||||
})
|
||||
|
||||
const organizationsRoute = createRoute({
|
||||
getParentRoute: () => appLayoutRoute,
|
||||
path: '/settings/organizations',
|
||||
component: OrganizationsPage,
|
||||
})
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
groupsRoute,
|
||||
paymentsRoute,
|
||||
settingsRoute,
|
||||
loginRoute,
|
||||
signupRoute,
|
||||
verifyEmailRoute,
|
||||
appLayoutRoute.addChildren([
|
||||
indexRoute,
|
||||
groupsRoute,
|
||||
paymentsRoute,
|
||||
settingsRoute,
|
||||
securityRoute,
|
||||
organizationsRoute,
|
||||
profileRoute,
|
||||
]),
|
||||
])
|
||||
|
||||
export const router = createRouter({ routeTree })
|
||||
@@ -46,4 +103,4 @@ declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
}
|
||||
113
apps/web/src/routes/auth/login.tsx
Normal file
113
apps/web/src/routes/auth/login.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { Fingerprint, Loader2 } from 'lucide-react'
|
||||
import { authClient } from '../../lib/auth-client'
|
||||
import { Button, Input, Label } from '../../components/ui'
|
||||
import { AuthShell } from '../../components/auth'
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email('Ingresá un email válido'),
|
||||
password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<FormValues>({ resolver: zodResolver(schema) })
|
||||
|
||||
const onSubmit = handleSubmit(async ({ email, password }) => {
|
||||
const { error } = await authClient.signIn.email({ email, password })
|
||||
if (error) {
|
||||
setError('root', { message: error.message ?? 'No se pudo iniciar sesión' })
|
||||
return
|
||||
}
|
||||
navigate({ to: '/' })
|
||||
})
|
||||
|
||||
const handleGoogle = async () => {
|
||||
await authClient.signIn.social({ provider: 'google', callbackURL: '/' })
|
||||
}
|
||||
|
||||
const handlePasskey = async () => {
|
||||
const { error, data } = await authClient.signIn.passkey()
|
||||
if (error) {
|
||||
setError('root', { message: error.message ?? 'No se pudo autenticar con passkey' })
|
||||
return
|
||||
}
|
||||
if (data) navigate({ to: '/' })
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell title="Iniciar sesión" subtitle="Accedé a tus cobros grupales">
|
||||
<div className="space-y-4">
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="vos@ejemplo.com"
|
||||
invalid={!!errors.email}
|
||||
{...register('email')}
|
||||
/>
|
||||
{errors.email ? <p className="mt-1 text-sm text-danger">{errors.email.message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="password">Contraseña</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
invalid={!!errors.password}
|
||||
{...register('password')}
|
||||
/>
|
||||
{errors.password ? (
|
||||
<p className="mt-1 text-sm text-danger">{errors.password.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{errors.root ? <p className="text-sm text-danger">{errors.root.message}</p> : null}
|
||||
|
||||
<Button type="submit" variant="primary" className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{isSubmitting ? 'Ingresando…' : 'Ingresar'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="text-xs uppercase tracking-wide text-foreground/40">o</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Button variant="outline" className="w-full" onClick={handleGoogle}>
|
||||
Continuar con Google
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full" onClick={handlePasskey}>
|
||||
<Fingerprint className="size-4" />
|
||||
Entrar con passkey
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-foreground/60">
|
||||
¿No tenés cuenta?{' '}
|
||||
<Link to="/signup" className="font-medium text-accent hover:underline">
|
||||
Registrate
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
142
apps/web/src/routes/auth/signup.tsx
Normal file
142
apps/web/src/routes/auth/signup.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { authClient } from '../../lib/auth-client'
|
||||
import { Button, Input, Label } from '../../components/ui'
|
||||
import { AuthShell } from '../../components/auth'
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.string().min(2, 'Ingresá tu nombre'),
|
||||
email: z.string().email('Ingresá un email válido'),
|
||||
password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'),
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((v) => v.password === v.confirmPassword, {
|
||||
message: 'Las contraseñas no coinciden',
|
||||
path: ['confirmPassword'],
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
export function SignupPage() {
|
||||
const navigate = useNavigate()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<FormValues>({ resolver: zodResolver(schema) })
|
||||
|
||||
const onSubmit = handleSubmit(async ({ name, email, password }) => {
|
||||
const { error, data } = await authClient.signUp.email(
|
||||
{ name, email, password },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await authClient.getSession()
|
||||
},
|
||||
},
|
||||
)
|
||||
if (error) {
|
||||
setError('root', { message: error.message ?? 'No se pudo registrar' })
|
||||
return
|
||||
}
|
||||
if (data?.token) {
|
||||
// Con email verification activo, la sesión no se crea hasta verificar.
|
||||
navigate({ to: '/verify-email', search: { email } })
|
||||
}
|
||||
})
|
||||
|
||||
const handleGoogle = async () => {
|
||||
await authClient.signIn.social({ provider: 'google', callbackURL: '/' })
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell title="Crear cuenta" subtitle="Empezá a cobrar en grupo en minutos">
|
||||
<div className="space-y-4">
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Nombre</Label>
|
||||
<Input
|
||||
id="name"
|
||||
autoComplete="name"
|
||||
placeholder="Nombre completo"
|
||||
invalid={!!errors.name}
|
||||
{...register('name')}
|
||||
/>
|
||||
{errors.name ? <p className="mt-1 text-sm text-danger">{errors.name.message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="vos@ejemplo.com"
|
||||
invalid={!!errors.email}
|
||||
{...register('email')}
|
||||
/>
|
||||
{errors.email ? <p className="mt-1 text-sm text-danger">{errors.email.message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="password">Contraseña</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="Mínimo 8 caracteres"
|
||||
invalid={!!errors.password}
|
||||
{...register('password')}
|
||||
/>
|
||||
{errors.password ? (
|
||||
<p className="mt-1 text-sm text-danger">{errors.password.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="confirmPassword">Repetí la contraseña</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="••••••••"
|
||||
invalid={!!errors.confirmPassword}
|
||||
{...register('confirmPassword')}
|
||||
/>
|
||||
{errors.confirmPassword ? (
|
||||
<p className="mt-1 text-sm text-danger">{errors.confirmPassword.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{errors.root ? <p className="text-sm text-danger">{errors.root.message}</p> : null}
|
||||
|
||||
<Button type="submit" variant="primary" className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
{isSubmitting ? 'Creando…' : 'Crear cuenta'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="text-xs uppercase tracking-wide text-foreground/40">o</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
|
||||
<Button variant="outline" className="w-full" onClick={handleGoogle}>
|
||||
Continuar con Google
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-foreground/60">
|
||||
¿Ya tenés cuenta?{' '}
|
||||
<Link to="/login" className="font-medium text-accent hover:underline">
|
||||
Iniciá sesión
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
91
apps/web/src/routes/auth/verify-email.tsx
Normal file
91
apps/web/src/routes/auth/verify-email.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { MailCheck, MailWarning, Loader2 } from 'lucide-react'
|
||||
import { authClient } from '../../lib/auth-client'
|
||||
import { AuthShell } from '../../components/auth'
|
||||
|
||||
type SearchParams = {
|
||||
token?: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
type Status = 'idle' | 'verifying' | 'success' | 'error'
|
||||
|
||||
export function VerifyEmailPage() {
|
||||
const { token, email } = useSearch({ strict: false }) as SearchParams
|
||||
const navigate = useNavigate()
|
||||
const [status, setStatus] = useState<Status>(token ? 'verifying' : 'idle')
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || status !== 'verifying') return
|
||||
let cancelled = false
|
||||
authClient
|
||||
.verifyEmail({ query: { token } })
|
||||
.then(async ({ error }) => {
|
||||
if (cancelled) return
|
||||
if (error) {
|
||||
setStatus('error')
|
||||
return
|
||||
}
|
||||
await authClient.getSession()
|
||||
navigate({ to: '/' })
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setStatus('error')
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [token, status, navigate])
|
||||
|
||||
const title =
|
||||
status === 'success'
|
||||
? 'Email verificado'
|
||||
: status === 'error'
|
||||
? 'Vínculo inválido'
|
||||
: 'Verificá tu email'
|
||||
|
||||
return (
|
||||
<AuthShell title={title}>
|
||||
<div className="flex flex-col items-center gap-4 py-2 text-center">
|
||||
{status === 'verifying' ? (
|
||||
<Loader2 className="size-10 animate-spin text-accent" />
|
||||
) : status === 'success' ? (
|
||||
<MailCheck className="size-10 text-success" />
|
||||
) : (
|
||||
<MailWarning className="size-10 text-warning" />
|
||||
)}
|
||||
|
||||
{status === 'success' ? (
|
||||
<p className="text-sm text-foreground/60">
|
||||
Tu cuenta quedó verificada. Te estamos llevando a Gruperly…
|
||||
</p>
|
||||
) : status === 'error' ? (
|
||||
<p className="text-sm text-foreground/60">
|
||||
El vínculo de verificación no es válido o expiró.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-foreground/60">
|
||||
Te enviamos un correo a <span className="font-medium text-primary">{email}</span> para
|
||||
confirmar tu cuenta.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status === 'success' ? (
|
||||
<Link
|
||||
to="/"
|
||||
className="w-full rounded-xl bg-accent py-2 text-center text-sm font-medium text-white hover:bg-accent-strong"
|
||||
>
|
||||
Ir a Gruperly
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{status === 'error' ? (
|
||||
<Link to="/login" className="text-sm font-medium text-accent hover:underline">
|
||||
Intentar iniciar sesión
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
191
apps/web/src/routes/organizations.tsx
Normal file
191
apps/web/src/routes/organizations.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Building2, Check, Loader2, Plus } from 'lucide-react'
|
||||
import { authClient } from '../lib/auth-client'
|
||||
import { Badge, Button, Input, Label } from '../components/ui'
|
||||
|
||||
const createOrgSchema = z.object({
|
||||
name: z.string().min(2, 'Ingresá el nombre del grupo'),
|
||||
slug: z
|
||||
.string()
|
||||
.min(2, 'Mínimo 2 caracteres')
|
||||
.regex(/^[a-z0-9-]+$/, 'Solo minúsculas, números y guiones'),
|
||||
})
|
||||
|
||||
type CreateOrgValues = z.infer<typeof createOrgSchema>
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4000'
|
||||
|
||||
type OrganizationRow = {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
logo: string | null
|
||||
}
|
||||
|
||||
function OrganizationList({ onRefresh }: { onRefresh: () => void }) {
|
||||
const { data, isPending } = authClient.useListOrganizations()
|
||||
const [syncing, setSyncing] = useState<string | null>(null)
|
||||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
|
||||
const organizations = (data ?? []) as OrganizationRow[]
|
||||
|
||||
const handleSyncGroup = async (org: OrganizationRow) => {
|
||||
setSyncing(org.id)
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/groups/from-organization`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ organizationId: org.id }),
|
||||
})
|
||||
const body = (await res.json()) as { message?: string }
|
||||
if (!res.ok) {
|
||||
onRefresh()
|
||||
setSyncing(null)
|
||||
return
|
||||
}
|
||||
void body
|
||||
} finally {
|
||||
setSyncing(null)
|
||||
}
|
||||
onRefresh()
|
||||
}
|
||||
|
||||
const handleSetActive = async (organizationId: string) => {
|
||||
setActiveId(organizationId)
|
||||
await authClient.organization.setActive({ organizationId })
|
||||
setActiveId(null)
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
return <Loader2 className="size-5 animate-spin text-foreground/40" />
|
||||
}
|
||||
|
||||
if (organizations.length === 0) {
|
||||
return <p className="text-sm text-foreground/60">Todavía no pertenecés a ningún grupo.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-border rounded-xl border border-border bg-white">
|
||||
{organizations.map((org) => (
|
||||
<li key={org.id} className="flex flex-wrap items-center gap-3 px-4 py-3">
|
||||
<span className="rounded-lg bg-accent-soft p-2">
|
||||
<Building2 className="size-4 text-accent" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-primary">{org.name}</p>
|
||||
<p className="text-xs text-foreground/50">/{org.slug}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={syncing === org.id}
|
||||
onClick={() => handleSyncGroup(org)}
|
||||
>
|
||||
{syncing === org.id ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
|
||||
Sincronizar grupo
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={activeId === org.id}
|
||||
onClick={() => handleSetActive(org.id)}
|
||||
>
|
||||
{activeId === org.id ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Check className="size-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline">Usar</span>
|
||||
</Button>
|
||||
<Badge>Owner</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export function OrganizationsPage() {
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
const [feedback, setFeedback] = useState<string | null>(null)
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<CreateOrgValues>({ resolver: zodResolver(createOrgSchema) })
|
||||
|
||||
const refreshOrganizations = () => setRefreshKey((k) => k + 1)
|
||||
|
||||
const onCreateOrg = handleSubmit(async ({ name, slug }) => {
|
||||
setFeedback(null)
|
||||
const { error, data } = await authClient.organization.create({ name, slug })
|
||||
if (error) {
|
||||
setError('root', { message: error.message ?? 'No se pudo crear el grupo' })
|
||||
return
|
||||
}
|
||||
reset({ name: '', slug: '' })
|
||||
setFeedback('Grupo creado. Sincronizalo con el cobro grupal.')
|
||||
refreshOrganizations()
|
||||
void data
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary">Grupos</h1>
|
||||
<p className="mt-1 text-sm text-foreground/60">
|
||||
Creá un grupo para organizar tus cobros. Cada organización se vincula a un grupo.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{feedback ? (
|
||||
<p className="rounded-xl bg-success-soft px-4 py-3 text-sm text-success">{feedback}</p>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-xl border border-border bg-white p-5">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Plus className="size-4 text-accent" />
|
||||
<h2 className="text-base font-semibold text-primary">Crear grupo</h2>
|
||||
</div>
|
||||
<form onSubmit={onCreateOrg} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Nombre</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Gimnasio, club, kermés…"
|
||||
invalid={!!errors.name}
|
||||
{...register('name')}
|
||||
/>
|
||||
{errors.name ? <p className="mt-1 text-sm text-danger">{errors.name.message}</p> : null}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="slug">Slug</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
placeholder="gimnasio-don-bosco"
|
||||
invalid={!!errors.slug}
|
||||
{...register('slug')}
|
||||
/>
|
||||
{errors.slug ? <p className="mt-1 text-sm text-danger">{errors.slug.message}</p> : null}
|
||||
</div>
|
||||
{errors.root ? <p className="text-sm text-danger">{errors.root.message}</p> : null}
|
||||
<Button type="submit" variant="primary" disabled={isSubmitting}>
|
||||
{isSubmitting ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
Crear grupo
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border bg-white p-5">
|
||||
<h2 className="mb-4 text-base font-semibold text-primary">Tus grupos</h2>
|
||||
<OrganizationList key={refreshKey} onRefresh={refreshOrganizations} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
27
apps/web/src/routes/profile.tsx
Normal file
27
apps/web/src/routes/profile.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useAuth } from '../context/AuthProvider'
|
||||
import { Avatar } from '../components/ui'
|
||||
|
||||
export function ProfileView() {
|
||||
const { user } = useAuth()
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary">Mi perfil</h1>
|
||||
<p className="mt-1 text-sm text-foreground/60">Tus datos personales.</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border bg-white p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar name={user?.name} src={user?.image ?? undefined} className="size-14 text-lg" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-base font-semibold text-primary">
|
||||
{user?.name ?? 'Usuario'}
|
||||
</p>
|
||||
<p className="truncate text-sm text-foreground/50">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
183
apps/web/src/routes/security.tsx
Normal file
183
apps/web/src/routes/security.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Fingerprint, KeyRound, Loader2, Plus, Trash2 } from 'lucide-react'
|
||||
import { authClient } from '../lib/auth-client'
|
||||
import { Button, Input, Label } from '../components/ui'
|
||||
|
||||
const changePasswordSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(1, 'Ingresá tu contraseña actual'),
|
||||
newPassword: z.string().min(8, 'La nueva contraseña debe tener al menos 8 caracteres'),
|
||||
})
|
||||
.refine((v) => v.currentPassword !== v.newPassword, {
|
||||
message: 'La nueva contraseña debe ser distinta',
|
||||
path: ['newPassword'],
|
||||
})
|
||||
|
||||
type ChangePasswordValues = z.infer<typeof changePasswordSchema>
|
||||
|
||||
function PasskeyList({ onRefresh }: { onRefresh: () => void }) {
|
||||
const { data, isPending } = authClient.useListPasskeys()
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setDeletingId(id)
|
||||
const { error } = await authClient.$fetch('/passkey/delete-passkey', {
|
||||
method: 'POST',
|
||||
body: { id },
|
||||
})
|
||||
setDeletingId(null)
|
||||
if (!error) onRefresh()
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
return <Loader2 className="size-5 animate-spin text-foreground/40" />
|
||||
}
|
||||
|
||||
const passkeys = data ?? []
|
||||
if (passkeys.length === 0) {
|
||||
return <p className="text-sm text-foreground/60">Todavía no registraste ninguna passkey.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-border rounded-xl border border-border bg-white">
|
||||
{passkeys.map((passkey) => (
|
||||
<li key={passkey.id} className="flex items-center gap-3 px-4 py-3">
|
||||
<span className="rounded-lg bg-accent-soft p-2">
|
||||
<Fingerprint className="size-4 text-accent" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-primary">
|
||||
{passkey.name ?? 'Passkey'}
|
||||
</p>
|
||||
<p className="text-xs text-foreground/50">
|
||||
{passkey.deviceType === 'singleDevice' ? 'Dispositivo' : 'Llave de seguridad'} ·{' '}
|
||||
{new Date(passkey.createdAt).toLocaleDateString('es-AR')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label="Eliminar passkey"
|
||||
disabled={deletingId === passkey.id}
|
||||
onClick={() => handleDelete(passkey.id)}
|
||||
>
|
||||
<Trash2 className="size-4 text-danger" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export function SecurityPage() {
|
||||
const [passkeyKeys, setPasskeyKeys] = useState(0)
|
||||
const [feedback, setFeedback] = useState<string | null>(null)
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setError,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<ChangePasswordValues>({ resolver: zodResolver(changePasswordSchema) })
|
||||
|
||||
const refreshPasskeys = () => {
|
||||
setPasskeyKeys((k) => k + 1)
|
||||
}
|
||||
|
||||
const handleAddPasskey = async () => {
|
||||
setFeedback(null)
|
||||
const { error } = await authClient.passkey.addPasskey()
|
||||
if (error) setFeedback(`No se pudo agregar: ${error.message}`)
|
||||
else {
|
||||
setFeedback('Passkey registrada correctamente.')
|
||||
refreshPasskeys()
|
||||
}
|
||||
}
|
||||
|
||||
const onChangePassword = handleSubmit(async ({ currentPassword, newPassword }) => {
|
||||
setFeedback(null)
|
||||
const { error } = await authClient.changePassword({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
revokeOtherSessions: true,
|
||||
})
|
||||
if (error) {
|
||||
setError('currentPassword', { message: error.message ?? 'No se pudo cambiar la contraseña' })
|
||||
return
|
||||
}
|
||||
reset({ currentPassword: '', newPassword: '' })
|
||||
setFeedback('Contraseña actualizada.')
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary">Seguridad</h1>
|
||||
<p className="mt-1 text-sm text-foreground/60">
|
||||
Gestioná tu contraseña y tus llaves de acceso (passkeys).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{feedback ? (
|
||||
<p className="rounded-xl bg-accent-soft px-4 py-3 text-sm text-accent-strong">{feedback}</p>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-xl border border-border bg-white p-5">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<KeyRound className="size-4 text-accent" />
|
||||
<h2 className="text-base font-semibold text-primary">Cambiar contraseña</h2>
|
||||
</div>
|
||||
<form onSubmit={onChangePassword} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="currentPassword">Contraseña actual</Label>
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
invalid={!!errors.currentPassword}
|
||||
{...register('currentPassword')}
|
||||
/>
|
||||
{errors.currentPassword ? (
|
||||
<p className="mt-1 text-sm text-danger">{errors.currentPassword.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="newPassword">Nueva contraseña</Label>
|
||||
<Input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
invalid={!!errors.newPassword}
|
||||
{...register('newPassword')}
|
||||
/>
|
||||
{errors.newPassword ? (
|
||||
<p className="mt-1 text-sm text-danger">{errors.newPassword.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button type="submit" variant="primary" disabled={isSubmitting}>
|
||||
{isSubmitting ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
Actualizar contraseña
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border bg-white p-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Fingerprint className="size-4 text-accent" />
|
||||
<h2 className="text-base font-semibold text-primary">Passkeys</h2>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleAddPasskey}>
|
||||
<Plus className="size-4" />
|
||||
Registrar
|
||||
</Button>
|
||||
</div>
|
||||
<PasskeyList key={passkeyKeys} onRefresh={refreshPasskeys} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,51 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Building2, Fingerprint } from 'lucide-react'
|
||||
import { signOut } from '../lib/auth-client'
|
||||
import { Button } from '../components/ui'
|
||||
|
||||
export function SettingsView() {
|
||||
const handleSignOut = async () => {
|
||||
await signOut({
|
||||
fetchOptions: { headers: { 'Cache-Control': 'no-cache' } },
|
||||
})
|
||||
window.location.assign('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h1 className="text-2xl font-bold text-primary">Ajustes</h1>
|
||||
<p className="mt-2 text-sm text-foreground/60">Configura tu cuenta.</p>
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary">Ajustes</h1>
|
||||
<p className="mt-1 text-sm text-foreground/60">Configurá tu cuenta y tus grupos.</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border rounded-xl border border-border bg-white">
|
||||
<Link
|
||||
to="/settings/organizations"
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-primary-soft"
|
||||
>
|
||||
<span className="rounded-lg bg-accent-soft p-2">
|
||||
<Building2 className="size-4 text-accent" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-primary">Grupos</p>
|
||||
<p className="text-xs text-foreground/50">Crear y gestionar tus organizaciones</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link to="/seguridad" className="flex items-center gap-3 px-4 py-3 hover:bg-primary-soft">
|
||||
<span className="rounded-lg bg-accent-soft p-2">
|
||||
<Fingerprint className="size-4 text-accent" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-primary">Seguridad</p>
|
||||
<p className="text-xs text-foreground/50">Contraseña y passkeys</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" onClick={handleSignOut}>
|
||||
Cerrar sesión
|
||||
</Button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
}
|
||||
1
apps/web/src/vite-env.d.ts
vendored
Normal file
1
apps/web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user