feat: basic initial UI layout for mobile and desktop

This commit is contained in:
Jose Selesan
2026-09-07 09:33:07 -03:00
parent 0aa5d70101
commit b41fffa40a
46 changed files with 2102 additions and 125 deletions

View File

@@ -1,6 +1,21 @@
# PostgreSQL connection string
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/gruperly?schema=public"
# Frontend origin (CORS + CSRF)
WEB_URL="http://localhost:6173"
# Better Auth
BETTER_AUTH_SECRET="replace-with-a-strong-secret"
BETTER_AUTH_URL="http://localhost:4000"
# Social providers
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
# Email (SMTP por ahora; migrar a un proveedor transaccional en el futuro)
EMAIL_PROVIDER="smtp"
EMAIL_SERVER_HOST=""
EMAIL_SERVER_PORT="587"
EMAIL_SERVER_USER=""
EMAIL_SERVER_PASSWORD=""
EMAIL_FROM="Gruperly <no-reply@gruperly.com>"

View File

@@ -14,15 +14,18 @@
"db:studio": "prisma studio"
},
"dependencies": {
"@better-auth/passkey": "^1.7.2",
"@gruperly/shared": "workspace:*",
"@hono/zod-validator": "^0.8.0",
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
"better-auth": "^1.1.0",
"hono": "^4.6.0"
"better-auth": "1.7.2",
"hono": "^4.6.0",
"nodemailer": "^9.0.6"
},
"devDependencies": {
"@types/bun": "^1.0.0",
"@types/nodemailer": "^8.0.1",
"prisma": "^7.10.0",
"typescript": "^5.7.0"
}

View File

@@ -0,0 +1,191 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('OWNER', 'ADMIN', 'MEMBER');
-- CreateEnum
CREATE TYPE "PaymentStatus" AS ENUM ('PENDING', 'PAID', 'OVERDUE', 'CANCELLED');
-- CreateEnum
CREATE TYPE "WaitlistStatus" AS ENUM ('PENDING', 'INVITED', 'JOINED', 'DECLINED');
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
"image" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "accounts" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refreshToken" TEXT,
"accessToken" TEXT,
"accessTokenExpiresAt" TIMESTAMP(3),
"refreshTokenExpiresAt" TIMESTAMP(3),
"scope" TEXT,
"idToken" TEXT,
"password" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "accounts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sessions" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"ipAddress" TEXT,
"userAgent" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "verifications" (
"id" TEXT NOT NULL,
"identifier" TEXT NOT NULL,
"value" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "verifications_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "groups" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"createdById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "groups_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "members" (
"id" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" "Role" NOT NULL DEFAULT 'MEMBER',
"joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "members_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "students" (
"id" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"fullName" TEXT NOT NULL,
"email" TEXT,
"phone" TEXT,
"guardianName" TEXT,
"guardianPhone" TEXT,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "students_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "payments" (
"id" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"studentId" TEXT NOT NULL,
"amount" DECIMAL(10,2) NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'MXN',
"status" "PaymentStatus" NOT NULL DEFAULT 'PENDING',
"dueDate" TIMESTAMP(3) NOT NULL,
"paidAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "payments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "waitlist_entries" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT,
"status" "WaitlistStatus" NOT NULL DEFAULT 'PENDING',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" TEXT,
CONSTRAINT "waitlist_entries_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "accounts_providerId_providerAccountId_key" ON "accounts"("providerId", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "sessions_token_key" ON "sessions"("token");
-- CreateIndex
CREATE UNIQUE INDEX "verifications_identifier_value_key" ON "verifications"("identifier", "value");
-- CreateIndex
CREATE UNIQUE INDEX "members_groupId_userId_key" ON "members"("groupId", "userId");
-- CreateIndex
CREATE INDEX "students_groupId_idx" ON "students"("groupId");
-- CreateIndex
CREATE INDEX "payments_groupId_idx" ON "payments"("groupId");
-- CreateIndex
CREATE INDEX "payments_studentId_idx" ON "payments"("studentId");
-- CreateIndex
CREATE UNIQUE INDEX "waitlist_entries_userId_key" ON "waitlist_entries"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "waitlist_entries_email_key" ON "waitlist_entries"("email");
-- AddForeignKey
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "groups" ADD CONSTRAINT "groups_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "members" ADD CONSTRAINT "members_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "groups"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "members" ADD CONSTRAINT "members_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "students" ADD CONSTRAINT "students_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "groups"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payments" ADD CONSTRAINT "payments_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "groups"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payments" ADD CONSTRAINT "payments_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "students"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "waitlist_entries" ADD CONSTRAINT "waitlist_entries_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,117 @@
/*
Warnings:
- You are about to drop the column `groupId` on the `members` table. All the data in the column will be lost.
- You are about to drop the column `joinedAt` on the `members` table. All the data in the column will be lost.
- The `role` column on the `members` table would be dropped and recreated. This will lead to data loss if there is data in the column.
- A unique constraint covering the columns `[organizationId,userId]` on the table `members` will be added. If there are existing duplicate values, this will fail.
- Added the required column `organizationId` to the `members` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "members" DROP CONSTRAINT "members_groupId_fkey";
-- DropIndex
DROP INDEX "members_groupId_userId_key";
-- AlterTable
ALTER TABLE "members" DROP COLUMN "groupId",
DROP COLUMN "joinedAt",
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "organizationId" TEXT NOT NULL,
DROP COLUMN "role",
ADD COLUMN "role" TEXT NOT NULL DEFAULT 'member';
-- AlterTable
ALTER TABLE "sessions" ADD COLUMN "activeOrganizationId" TEXT;
-- CreateTable
CREATE TABLE "organizations" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"logo" TEXT,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "organizations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "invitations" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"role" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"inviterId" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "invitations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "passkeys" (
"id" TEXT NOT NULL,
"name" TEXT,
"publicKey" TEXT NOT NULL,
"credentialID" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"counter" INTEGER NOT NULL,
"deviceType" TEXT NOT NULL,
"backedUp" BOOLEAN NOT NULL,
"transports" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"aaguid" TEXT,
CONSTRAINT "passkeys_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "group_members" (
"id" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" "Role" NOT NULL DEFAULT 'MEMBER',
"joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "group_members_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "organizations_slug_key" ON "organizations"("slug");
-- CreateIndex
CREATE INDEX "invitations_organizationId_idx" ON "invitations"("organizationId");
-- CreateIndex
CREATE UNIQUE INDEX "passkeys_credentialID_key" ON "passkeys"("credentialID");
-- CreateIndex
CREATE INDEX "passkeys_userId_idx" ON "passkeys"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "group_members_groupId_userId_key" ON "group_members"("groupId", "userId");
-- CreateIndex
CREATE UNIQUE INDEX "members_organizationId_userId_key" ON "members"("organizationId", "userId");
-- AddForeignKey
ALTER TABLE "members" ADD CONSTRAINT "members_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_inviterId_fkey" FOREIGN KEY ("inviterId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "passkeys" ADD CONSTRAINT "passkeys_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "group_members" ADD CONSTRAINT "group_members_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "groups"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "group_members" ADD CONSTRAINT "group_members_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,22 @@
/*
Warnings:
- You are about to drop the column `providerAccountId` on the `accounts` table. All the data in the column will be lost.
- A unique constraint covering the columns `[issuer,accountId]` on the table `accounts` will be added. If there are existing duplicate values, this will fail.
- Added the required column `accountId` to the `accounts` table without a default value. This is not possible if the table is not empty.
- Added the required column `issuer` to the `accounts` table without a default value. This is not possible if the table is not empty.
*/
-- DropIndex
DROP INDEX "accounts_providerId_providerAccountId_key";
-- AlterTable
ALTER TABLE "accounts" DROP COLUMN "providerAccountId",
ADD COLUMN "accountId" TEXT NOT NULL,
ADD COLUMN "issuer" TEXT NOT NULL;
-- CreateIndex
CREATE INDEX "accounts_userId_idx" ON "accounts"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "accounts_issuer_accountId_key" ON "accounts"("issuer", "accountId");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -19,45 +19,51 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
sessions Session[]
groupsMember Member[]
groupsOwner Group[] @relation("OwnerGroups")
waitlist WaitlistEntry[]
accounts Account[]
sessions Session[]
passkeys Passkey[]
groupsMember GroupMember[]
groupsOwner Group[] @relation("OwnerGroups")
orgMemberships Member[]
orgInvitations Invitation[] @relation("InvitedBy")
waitlist WaitlistEntry[]
@@map("users")
}
model Account {
id String @id @default(cuid())
userId String
providerId String
providerAccountId String
refreshToken String?
accessToken String?
id String @id @default(cuid())
userId String
providerId String
accountId String
issuer String
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
idToken String?
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
scope String?
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([providerId, providerAccountId])
@@unique([issuer, accountId])
@@index([userId])
@@map("accounts")
}
model Session {
id String @id @default(cuid())
userId String
token String @unique
expiresAt DateTime
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
userId String
token String @unique
expiresAt DateTime
ipAddress String?
userAgent String?
activeOrganizationId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -76,6 +82,73 @@ model Verification {
@@map("verifications")
}
// Better Auth - Organization plugin
model Organization {
id String @id @default(cuid())
name String
slug String @unique
logo String?
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members Member[]
invitations Invitation[]
@@map("organizations")
}
model Member {
id String @id @default(cuid())
organizationId String
userId String
role String @default("member")
createdAt DateTime @default(now())
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([organizationId, userId])
@@map("members")
}
model Invitation {
id String @id @default(cuid())
organizationId String
email String
role String
status String @default("pending")
inviterId String
expiresAt DateTime
createdAt DateTime @default(now())
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
inviter User @relation("InvitedBy", fields: [inviterId], references: [id], onDelete: Cascade)
@@index([organizationId])
@@map("invitations")
}
// Better Auth - Passkey plugin
model Passkey {
id String @id @default(cuid())
name String?
publicKey String
credentialID String @unique
userId String
counter Int
deviceType String
backedUp Boolean
transports String?
createdAt DateTime @default(now())
aaguid String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("passkeys")
}
// Domain models
model Group {
id String @id @default(cuid())
@@ -85,26 +158,26 @@ model Group {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation("OwnerGroups", fields: [createdById], references: [id], onDelete: Cascade)
members Member[]
owner User @relation("OwnerGroups", fields: [createdById], references: [id], onDelete: Cascade)
members GroupMember[]
students Student[]
payments Payment[]
@@map("groups")
}
model Member {
id String @id @default(cuid())
groupId String
userId String
role Role @default(MEMBER) // OWNER, ADMIN, MEMBER
model GroupMember {
id String @id @default(cuid())
groupId String
userId String
role Role @default(MEMBER) // OWNER, ADMIN, MEMBER
joinedAt DateTime @default(now())
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([groupId, userId])
@@map("members")
@@map("group_members")
}
enum Role {

63
apps/api/src/auth.ts Normal file
View File

@@ -0,0 +1,63 @@
import { betterAuth } from 'better-auth'
import { prismaAdapter } from 'better-auth/adapters/prisma'
import { passkey } from '@better-auth/passkey'
import { organization } from 'better-auth/plugins/organization'
import { prisma } from './db'
import { emailProvider } from './lib/email'
export const auth = betterAuth({
appName: 'Gruperly',
database: prismaAdapter(prisma, {
provider: 'postgresql',
}),
emailAndPassword: {
enabled: true,
minPasswordLength: 8,
sendResetPassword: async ({ user, url }) => {
await emailProvider.sendPasswordResetEmail({
email: user.email,
url,
name: user.name,
})
},
},
emailVerification: {
sendOnSignUp: true,
sendOnSignIn: true,
autoSignInAfterVerification: true,
sendVerificationEmail: async ({ user, token }) => {
const webUrl = process.env.WEB_URL ?? 'http://localhost:6173'
const verificationUrl = new URL('/verify-email', webUrl)
verificationUrl.searchParams.set('token', token)
await emailProvider.sendVerificationEmail({
email: user.email,
url: verificationUrl.toString(),
name: user.name,
})
},
},
socialProviders: {
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
? {
google: {
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
},
}
: {}),
},
plugins: [
organization({
acronym: 'GRP',
allowUserToCreateOrganization: true,
organizationLimit: 10,
}),
passkey({
rpName: 'Gruperly',
}),
],
trustedOrigins: [
process.env.BETTER_AUTH_URL ?? 'http://localhost:4000',
process.env.WEB_URL ?? 'http://localhost:6173',
],
})

10
apps/api/src/env.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { auth } from './auth'
type Session = typeof auth.$Infer.Session
export type AppEnv = {
Variables: {
user: Session['user'] | null
session: Session['session'] | null
}
}

View File

@@ -1,22 +1,50 @@
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { authRoutes } from './routes/auth'
import { groupsRoutes } from './routes/groups'
import { studentsRoutes } from './routes/students'
import { paymentsRoutes } from './routes/payments'
import { waitlistRoutes } from './routes/waitlist'
import { auth } from './auth'
import type { AppEnv } from './env'
import { listGroups, createGroupFromOrganization } from './routes/groups'
import { listPlaceholder as students } from './routes/students'
import { listPlaceholder as payments } from './routes/payments'
import { listPlaceholder as waitlist } from './routes/waitlist'
const app = new Hono()
const app = new Hono<AppEnv>()
app.use('*', cors())
const webOrigin = process.env.WEB_URL ?? 'http://localhost:6173'
app.use(
'*',
cors({
origin: [webOrigin],
credentials: true,
allowHeaders: ['Content-Type', 'Authorization'],
maxAge: 600,
}),
)
// BetterAuth endpoints (sign-up, sign-in, session, org, passkeys, ...)
app.all('/api/auth', (c) => auth.handler(c.req.raw))
app.all('/api/auth/*', (c) => auth.handler(c.req.raw))
// Sesión disponible en todos los handlers vía c.get('user') / c.get('session')
app.use('*', async (c, next) => {
const session = await auth.api.getSession({ headers: c.req.raw.headers })
if (session) {
c.set('user', session.user)
c.set('session', session.session)
} else {
c.set('user', null)
c.set('session', null)
}
await next()
})
app.get('/', (c) => c.json({ name: 'Gruperly API', status: 'ok' }))
app.route('/auth', authRoutes)
app.route('/groups', groupsRoutes)
app.route('/students', studentsRoutes)
app.route('/payments', paymentsRoutes)
app.route('/waitlist', waitlistRoutes)
app.get('/api/groups', listGroups)
app.post('/api/groups/from-organization', createGroupFromOrganization)
app.get('/api/students', students)
app.get('/api/payments', payments)
app.get('/api/waitlist', waitlist)
export default app
@@ -27,4 +55,4 @@ Bun.serve({
fetch: app.fetch,
})
console.log(`Gruperly API running on http://localhost:${port}`)
console.log(`Gruperly API running on http://localhost:${port}`)

94
apps/api/src/lib/email.ts Normal file
View File

@@ -0,0 +1,94 @@
import nodemailer, { type Transporter } from 'nodemailer'
type VerificationEmailData = { email: string; url: string; name?: string }
type PasswordResetEmailData = { email: string; url: string; name?: string }
type InvitationEmailData = { email: string; url: string; organizationName: string }
interface EmailProvider {
sendVerificationEmail(data: VerificationEmailData): Promise<void>
sendPasswordResetEmail(data: PasswordResetEmailData): Promise<void>
sendOrganizationInvitation(data: InvitationEmailData): Promise<void>
}
class SMTPEmailProvider implements EmailProvider {
private transporter: Transporter
constructor() {
this.transporter = nodemailer.createTransport({
host: process.env.EMAIL_SERVER_HOST,
port: Number(process.env.EMAIL_SERVER_PORT ?? 587),
secure: (process.env.EMAIL_SERVER_PORT ?? '587') === '465',
auth: {
user: process.env.EMAIL_SERVER_USER,
pass: process.env.EMAIL_SERVER_PASSWORD,
},
})
}
private from() {
return process.env.EMAIL_FROM ?? 'Gruperly <no-reply@gruperly.com>'
}
private layout(title: string, body: string) {
return `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px; color: #0a2540;">
<h2 style="margin: 0 0 16px;">Gruperly</h2>
<h3 style="margin: 0 0 8px;">${title}</h3>
<p style="line-height: 1.6; color: #334155;">${body}</p>
</div>
`
}
private async send(to: string, subject: string, html: string) {
// Sin SMTP configurado (dev) no rompe el flujo: solo registra un aviso
if (!process.env.EMAIL_SERVER_HOST) {
console.warn(`[email] SMTP no configurado. Email no enviado a ${to}: "${subject}"`)
return
}
await this.transporter.sendMail({ from: this.from(), to, subject, html })
}
async sendVerificationEmail({ email, url, name }: VerificationEmailData) {
const link = `<a href="${url}" style="display:inline-block;background:#1e90ff;color:#fff;text-decoration:none;padding:12px 24px;border-radius:12px;font-weight:600;">Verificar email</a>`
await this.send(
email,
'Verifica tu email en Gruperly',
this.layout(
'Confirma tu dirección de email',
`${name ? `Hola ${name}, ` : 'Hola, '}gracias por crear tu cuenta en Gruperly. Para empezar, verifica tu email con el botón de abajo (el enlace vence en 1 hora).<br/><br/>${link}`,
),
)
}
async sendPasswordResetEmail({ email, url, name }: PasswordResetEmailData) {
const link = `<a href="${url}" style="display:inline-block;background:#1e90ff;color:#fff;text-decoration:none;padding:12px 24px;border-radius:12px;font-weight:600;">Restablecer contraseña</a>`
await this.send(
email,
'Restablece tu contraseña en Gruperly',
this.layout(
'Solicitaste restablecer tu contraseña',
`${name ? `Hola ${name}, ` : 'Hola, '}haz clic en el botón para elegir una nueva contraseña (el enlace vence en 1 hora). Si no fuiste tú, ignora este email.<br/><br/>${link}`,
),
)
}
async sendOrganizationInvitation({ email, url, organizationName }: InvitationEmailData) {
const link = `<a href="${url}" style="display:inline-block;background:#1e90ff;color:#fff;text-decoration:none;padding:12px 24px;border-radius:12px;font-weight:600;">Unirme a ${organizationName}</a>`
await this.send(
email,
`Te invitaron a ${organizationName} en Gruperly`,
this.layout(
`Te invitaron a ${organizationName}`,
`Acepta la invitación con el botón de abajo para empezar a colaborar en Gruperly.<br/><br/>${link}`,
),
)
}
}
// Futuro: proveedores transaccionales (Resend, SendGrid, Postmark).
// Añadir implementaciones y alternar con EMAIL_PROVIDER.
export function createEmailProvider(): EmailProvider {
return new SMTPEmailProvider()
}
export const emailProvider = createEmailProvider()

View File

@@ -1,5 +0,0 @@
import { Hono } from 'hono'
export const authRoutes = new Hono()
authRoutes.get('/session', (c) => c.json({ message: 'placeholder' }))

View File

@@ -1,5 +1,65 @@
import { Hono } from 'hono'
import type { Context } from 'hono'
import { z } from 'zod'
import { prisma } from '../db/client'
import type { AppEnv } from '../env'
import { Role } from '../../prisma/generated/prisma/client'
export const groupsRoutes = new Hono()
const fromOrganizationSchema = z.object({
organizationId: z.string().min(1),
})
groupsRoutes.get('/', (c) => c.json([]))
export async function listGroups(c: Context<AppEnv>) {
const user = c.get('user')
if (!user) return c.json({ message: 'No autorizado' }, 401)
void user
return c.json([])
}
// Crea un Group a partir de una Organization de Better Auth (mapeo 1:1).
// Solo el owner de la organización puede crear su grupo.
export async function createGroupFromOrganization(c: Context<AppEnv>) {
const user = c.get('user')
if (!user) return c.json({ message: 'No autorizado' }, 401)
const body = await c.req.json().catch(() => null)
const parsed = fromOrganizationSchema.safeParse(body)
if (!parsed.success) {
return c.json({ message: 'Cuerpo inválido', issues: parsed.error.issues }, 400)
}
const organization = await prisma.organization.findUnique({
where: { id: parsed.data.organizationId },
include: { members: true },
})
if (!organization) return c.json({ message: 'Organización no encontrada' }, 404)
const membership = organization.members.find((m) => m.userId === user.id)
if (!membership || membership.role !== 'owner') {
return c.json({ message: 'Solo el owner puede crear el grupo' }, 403)
}
const existing = await prisma.group.findFirst({
where: { createdById: user.id, name: organization.name },
})
if (existing) return c.json({ group: existing, alreadyExists: true })
const group = await prisma.$transaction(async (tx) => {
const created = await tx.group.create({
data: {
name: organization.name,
createdById: user.id,
},
})
await tx.groupMember.create({
data: {
groupId: created.id,
userId: user.id,
role: Role.OWNER,
},
})
return created
})
return c.json({ group }, 201)
}

View File

@@ -1,5 +1,5 @@
import { Hono } from 'hono'
import type { Context } from 'hono'
export const paymentsRoutes = new Hono()
paymentsRoutes.get('/', (c) => c.json([]))
export function listPlaceholder(c: Context) {
return c.json([])
}

View File

@@ -1,5 +1,5 @@
import { Hono } from 'hono'
import type { Context } from 'hono'
export const studentsRoutes = new Hono()
studentsRoutes.get('/', (c) => c.json([]))
export function listPlaceholder(c: Context) {
return c.json([])
}

View File

@@ -1,5 +1,5 @@
import { Hono } from 'hono'
import type { Context } from 'hono'
export const waitlistRoutes = new Hono()
waitlistRoutes.get('/', (c) => c.json([]))
export function listPlaceholder(c: Context) {
return c.json([])
}

View File

@@ -3,6 +3,7 @@
"compilerOptions": {
"moduleResolution": "bundler",
"types": ["bun"],
"declaration": false,
"paths": {
"@gruperly/shared": ["../../packages/shared/src/index.ts"]
}

1
apps/web/.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_API_URL=http://localhost:4000

View File

@@ -10,10 +10,12 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@better-auth/passkey": "^1.7.2",
"@gruperly/shared": "workspace:*",
"@hookform/resolvers": "^3.9.0",
"@tanstack/react-query": "^5.62.0",
"@tanstack/react-router": "^1.90.0",
"better-auth": "1.7.2",
"clsx": "^2.1.1",
"lucide-react": "^1.34.0",
"react": "^19.0.0",

View 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>
)
}

View File

@@ -0,0 +1 @@
export { AuthShell } from './AuthShell'

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)
}
}

View File

@@ -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" />

View 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>
)
}

View File

@@ -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'

View File

@@ -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'

View 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'

View 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}
/>
)
}

View 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
}

View 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
}

View File

@@ -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 {

View 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

View File

@@ -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>,
)
)

View File

@@ -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
}
}
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -4,6 +4,7 @@
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"moduleResolution": "bundler",
"declaration": false,
"paths": {
"@gruperly/shared": ["../../packages/shared/src/index.ts"]
}

View File

@@ -12,15 +12,18 @@
"name": "@gruperly/api",
"version": "0.1.0",
"dependencies": {
"@better-auth/passkey": "^1.7.2",
"@gruperly/shared": "workspace:*",
"@hono/zod-validator": "^0.8.0",
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
"better-auth": "^1.1.0",
"better-auth": "1.7.2",
"hono": "^4.6.0",
"nodemailer": "^9.0.6",
},
"devDependencies": {
"@types/bun": "^1.0.0",
"@types/nodemailer": "^8.0.1",
"prisma": "^7.10.0",
"typescript": "^5.7.0",
},
@@ -29,10 +32,12 @@
"name": "@gruperly/web",
"version": "0.1.0",
"dependencies": {
"@better-auth/passkey": "^1.7.2",
"@gruperly/shared": "workspace:*",
"@hookform/resolvers": "^3.9.0",
"@tanstack/react-query": "^5.62.0",
"@tanstack/react-router": "^1.90.0",
"better-auth": "1.7.2",
"clsx": "^2.1.1",
"lucide-react": "^1.34.0",
"react": "^19.0.0",
@@ -106,19 +111,21 @@
"@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
"@better-auth/core": ["@better-auth/core@1.7.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.41.1", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.4.0", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-eZ9lqcnVLMZ3QtUByRo4VZqkB1ESyRddd9NfWjBdDPgh+jcwLScoIUAqhtHLR8zaSUJZah8OLGlkzObyPdUH7A=="],
"@better-auth/core": ["@better-auth/core@1.7.2", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.41.1", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.4.0", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-j0nM4ygsWbF/fcYRoKtDn8gn8uLXkmC+075HqSqsJEAV828cJR9bvYBCUQ1zmxNyRBk6Iz/qXsA0Zm2oksiOTg=="],
"@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.7.1", "", { "peerDependencies": { "@better-auth/core": "^1.7.1", "@better-auth/utils": "0.4.2", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-qlqNyg5V9bXHSP68/vtlsiZayhR4hgvEGiS/E3SIj8bCpWWFGmyQkxJbQCqpBmC7vT30wE/kNtJMHIgnV3rkiw=="],
"@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.7.2", "", { "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-A5wE10PIv3aS5LGePecEHntQylKy6OOF17B4dqlE0DwJeqU/IOBSd7/LZhMop9cNJ3WFjKMpazVSf91yYM/NFg=="],
"@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.7.1", "", { "peerDependencies": { "@better-auth/core": "^1.7.1", "@better-auth/utils": "0.4.2", "kysely": "^0.28.17 || ^0.29.0" }, "optionalPeers": ["kysely"] }, "sha512-yWCpE1cZpMUj37nD6JFDK+GDR8zS37L5WI73il3qbU9TXtWsxUQKc/5c3IHsHizWQsmcQI8uv2pAFKxsRDa+AQ=="],
"@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.7.2", "", { "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "kysely": "^0.28.17 || ^0.29.0" }, "optionalPeers": ["kysely"] }, "sha512-LYdSRLOvZiF+6S0UThu+wE/Qxsq9P2jQs7ZKkY6BIBJqUjYyxVDmi8HFcantBvWWW1/BeQCSsD7YVDG4gICMIQ=="],
"@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.7.1", "", { "peerDependencies": { "@better-auth/core": "^1.7.1", "@better-auth/utils": "0.4.2" } }, "sha512-6NX1yv88DeqdoG7owYFqKwlrDGaIPhsC52JUGrUgeGVKyOq8a/6hHlHsqG1C2FwT23SHQiKVYCEAG9N6aH5OvQ=="],
"@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.7.2", "", { "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2" } }, "sha512-0q1SXMzm5esH9L0xVuM6IxCk59E4G+3HySX4My9gvEwqtmUobykn+iuc/si3Y4xwUO7JODqQ5o+/pPcLDDMIrA=="],
"@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.7.1", "", { "peerDependencies": { "@better-auth/core": "^1.7.1", "@better-auth/utils": "0.4.2", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-9ILTcNqhG37QK//qR4UhYLyKzNqq6w6zVTf5KX6xkiTjNcV7Oh1yS31lkIJEVTqRNcy9AoV6FZMW6Bbsm8IMDA=="],
"@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.7.2", "", { "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-4879SmUWHUs0OYlvHoCFbycZ7i1bqytkcgAUdt9RLQMvZ5H3LRMTgax2YVlGZEXgwNjY/X7xAoXOecWLhlQWeA=="],
"@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.7.1", "", { "peerDependencies": { "@better-auth/core": "^1.7.1", "@better-auth/utils": "0.4.2", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-ZiUcafQ85InAofcUjyGgCPjKLfQjXr9SvDmMjuFUW8oEbreA6C6GaFAEA77VuV2doZUQlzvQQ4gCoTmS28W92A=="],
"@better-auth/passkey": ["@better-auth/passkey@1.7.2", "", { "dependencies": { "@simplewebauthn/browser": "^13.3.0", "@simplewebauthn/server": "^13.3.1", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "better-auth": "^1.7.2", "better-call": "1.4.0", "nanostores": "^1.0.1" } }, "sha512-KBK852b+HsCdstVPPDHsuRa9Rc+7IEuRMPQboi/OXNOwgKL8GwHpzzWD2WhiT/FXJPrLCPh8vHJQfc1wdl5OZw=="],
"@better-auth/telemetry": ["@better-auth/telemetry@1.7.1", "", { "peerDependencies": { "@better-auth/core": "^1.7.1", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1" } }, "sha512-kLKjMfFlTbyt49DGeI9okHAsn0MtBZcMoQYKaEdgR0H3BHzqqyzePcQz/hxAmRgjB4p/6inise3zJwhX0sgXrQ=="],
"@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.7.2", "", { "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-mXTr/83WrNWLrvzIjtgDgdu9iXhOcSG1+qBQOAKlbGSFiOB+z4IMRneQ2wmMOiB8mKY9qGkClVUjKRFXqtHnFQ=="],
"@better-auth/telemetry": ["@better-auth/telemetry@1.7.2", "", { "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1" } }, "sha512-LcWu+O0zrxYDQj8E36vfkJwGPW4k9ZDA/rCo0zST6ihzL+juR7pBowoZIM9E6tK0Vit52mf6412bGT4XM4eTjQ=="],
"@better-auth/utils": ["@better-auth/utils@0.4.2", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A=="],
@@ -190,6 +197,8 @@
"@gruperly/web": ["@gruperly/web@workspace:apps/web"],
"@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="],
"@hono/zod-validator": ["@hono/zod-validator@0.8.0", "", { "peerDependencies": { "hono": ">=4.10.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-5uS4S1/LKtZQYvD4BtpPUFkOv8d1wNxHHrChm26buMiEYc1FrHWvDUaKVBwkiVtvSExHSpLGDvcnpI2Copyj9w=="],
"@hookform/resolvers": ["@hookform/resolvers@3.10.0", "", { "peerDependencies": { "react-hook-form": "^7.0.0" } }, "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag=="],
@@ -204,6 +213,8 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="],
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
"@noble/ciphers": ["@noble/ciphers@2.3.0", "", {}, "sha512-Clu/xdfgVTf9o7ngLOURaxePwR0j8sjclKEtVij10/jGulwFsPWCvvRgG/XjUVf8Nei+jLG6uwyXzUTGY1DQrw=="],
@@ -212,6 +223,32 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
"@peculiar/asn1-android": ["@peculiar/asn1-android@2.9.4", "", { "dependencies": { "@peculiar/asn1-schema": "^2.9.4", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-SYHm4SoWSI0nRCoos6jpGusIqhPH9bbGBqv7ohlZ+H6BunrDzzQPk2ePgDuEUzV82OdvbLgtW4twUDwhU9P3YQ=="],
"@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.9.4", "", { "dependencies": { "@peculiar/asn1-schema": "^2.9.4", "@peculiar/asn1-x509": "^2.9.4", "@peculiar/asn1-x509-attr": "^2.9.4", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-cben7oxmQsUGZqotus7yt0srYdncOT6RNWcTQ77T2RFOXejYVYkXadrfePdRcrVpO9K95IRLKKglG2k38jKXuw=="],
"@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.9.4", "", { "dependencies": { "@peculiar/asn1-schema": "^2.9.4", "@peculiar/asn1-x509": "^2.9.4", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-xd4YN4vpRjkDAQWVfZZkeu12IEND7DOpkqaHSIHxZl1uggUNa9Ju0QxY2jHvDAS9pP0zhRBytg8ifsnGo3V0jw=="],
"@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.9.4", "", { "dependencies": { "@peculiar/asn1-schema": "^2.9.4", "@peculiar/asn1-x509": "^2.9.4", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-JJXefFshRAuVAjWQo/39bkg1ywc1VaiO44S8RRC+Ykvf/u2KDmYffoDb0ZBPCR5uJy4AGKQhl8mX+Q8ShcWaXQ=="],
"@peculiar/asn1-pfx": ["@peculiar/asn1-pfx@2.9.4", "", { "dependencies": { "@peculiar/asn1-cms": "^2.9.4", "@peculiar/asn1-pkcs8": "^2.9.4", "@peculiar/asn1-rsa": "^2.9.4", "@peculiar/asn1-schema": "^2.9.4", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-khuGzHTzNzk4GDlIBEILyIs6Lce0yn0ZBdoI9v93kmNncfZRhD+AQ5ODFqdhvoE8cMJF/JMTQ8yA+t1D14kqCw=="],
"@peculiar/asn1-pkcs8": ["@peculiar/asn1-pkcs8@2.9.4", "", { "dependencies": { "@peculiar/asn1-schema": "^2.9.4", "@peculiar/asn1-x509": "^2.9.4", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-duRdotlUx9eDZe6QrQpQKl61RbWykCHBCkKayP8V8XdEFwlKHZ8qGGDMyS6Pye7OX7nLFttTTpRkJeet78ckwQ=="],
"@peculiar/asn1-pkcs9": ["@peculiar/asn1-pkcs9@2.9.4", "", { "dependencies": { "@peculiar/asn1-cms": "^2.9.4", "@peculiar/asn1-pfx": "^2.9.4", "@peculiar/asn1-pkcs8": "^2.9.4", "@peculiar/asn1-schema": "^2.9.4", "@peculiar/asn1-x509": "^2.9.4", "@peculiar/asn1-x509-attr": "^2.9.4", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-kaL4cNxBpdQE2dKlyZBqz4ygCrwffO+8wfoxTEqM1Z8RadvCeELBRzcv0dzM8aY9azHMwODO5nxU65zXmhToOQ=="],
"@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.9.4", "", { "dependencies": { "@peculiar/asn1-schema": "^2.9.4", "@peculiar/asn1-x509": "^2.9.4", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-pZ96eD1PptovcWQ/GSmuNFXd/7EQJNlKfDaNCyE2rx3W0v6QFelkzquVqRSRyyDXXCYD69ZXJDzZ8GhIiQzKoA=="],
"@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.9.4", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg=="],
"@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.9.4", "", { "dependencies": { "@peculiar/asn1-schema": "^2.9.4", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-CxhBo/RdEbMMob7T31ZdQjGuoyRFLVwrDzTn25bihzBasRg9kRm/0IxIPvhgQtcK/9dNcO1XQL2fuPugwELL0Q=="],
"@peculiar/asn1-x509-attr": ["@peculiar/asn1-x509-attr@2.9.4", "", { "dependencies": { "@peculiar/asn1-schema": "^2.9.4", "@peculiar/asn1-x509": "^2.9.4", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-ehQXbpQaQYycgu8OrvigwSPTFfVRcu0ECNYCWw+yzBp02Lw5paRqzzhUpfOgO2K38+WfFZuEz/0RPtam5g0OMg=="],
"@peculiar/utils": ["@peculiar/utils@2.0.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ=="],
"@peculiar/x509": ["@peculiar/x509@1.14.3", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-csr": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.0", "@peculiar/asn1-pkcs9": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA=="],
"@prisma/adapter-pg": ["@prisma/adapter-pg@7.10.0", "", { "dependencies": { "@prisma/driver-adapter-utils": "7.10.0", "@types/pg": "^8.16.0", "pg": "^8.16.3", "postgres-array": "3.0.4" } }, "sha512-N7nwSor0HO1Kz6xBv0TPAjAPysKK0fac6p4fVN3ensLOuzc/83Fgmln5k92eK/cvzqdkSR/2kkAqlbcdwVrwpw=="],
"@prisma/client": ["@prisma/client@7.10.0", "", { "dependencies": { "@prisma/client-runtime-utils": "7.10.0" }, "peerDependencies": { "prisma": "*", "typescript": ">=5.4.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-Ubw/QS9JGIBSBUsyxAUQuK/Jcu0Tsva7le7QbLd91Kix9yJvYDdj5QkwgEbbZniH80dd+sziQcALPc+HnvQC8Q=="],
@@ -308,6 +345,10 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.5", "", { "os": "win32", "cpu": "x64" }, "sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg=="],
"@simplewebauthn/browser": ["@simplewebauthn/browser@13.3.0", "", {}, "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ=="],
"@simplewebauthn/server": ["@simplewebauthn/server@13.3.3", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/x509": "^1.14.3" } }, "sha512-LelX/lcy5cjc15A86i/aNxHhB5eU7dd20QsbP0VLAf9e38+SLlsnqCCyecx3xqfGofhmX05h1J9fKRYWxw+luA=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
@@ -406,6 +447,8 @@
"@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="],
"@types/nodemailer": ["@types/nodemailer@8.0.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw=="],
"@types/pg": ["@types/pg@8.23.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A=="],
"@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
@@ -434,11 +477,13 @@
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="],
"aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw=="],
"better-auth": ["better-auth@1.7.1", "", { "dependencies": { "@better-auth/core": "1.7.1", "@better-auth/drizzle-adapter": "1.7.1", "@better-auth/kysely-adapter": "1.7.1", "@better-auth/memory-adapter": "1.7.1", "@better-auth/mongo-adapter": "1.7.1", "@better-auth/prisma-adapter": "1.7.1", "@better-auth/telemetry": "1.7.1", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@noble/ciphers": "^2.2.0", "@noble/hashes": "^2.2.0", "better-call": "1.4.0", "defu": "^6.1.4", "jose": "^6.2.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.3.0", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4 || >=1.0.0-beta.1", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-g8WlTQijxXWJjPVZfFu1+EJg9cwwHrKDmIkcYMzx8CzYA+tDxl6NI7qQbKkbgw5UtHILsT5VH+RMzFzwnVJqAg=="],
"better-auth": ["better-auth@1.7.2", "", { "dependencies": { "@better-auth/core": "1.7.2", "@better-auth/drizzle-adapter": "1.7.2", "@better-auth/kysely-adapter": "1.7.2", "@better-auth/memory-adapter": "1.7.2", "@better-auth/mongo-adapter": "1.7.2", "@better-auth/prisma-adapter": "1.7.2", "@better-auth/telemetry": "1.7.2", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@noble/ciphers": "^2.2.0", "@noble/hashes": "^2.2.0", "better-call": "1.4.0", "defu": "^6.1.4", "jose": "^6.2.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.3.0", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4 || >=1.0.0-beta.1", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-gKapKBEvYIGcMxi74RjQ7EbFLiqyQt58vdoJmL1qAlWSkY1Bc2Vqshl524/3u1NxauiOU03M/Ebh762Brmac9A=="],
"better-call": ["better-call@1.4.0", "", { "dependencies": { "@better-auth/utils": "^0.5.0", "@better-fetch/fetch": "^1.3.1", "rou3": "^0.9.1", "set-cookie-parser": "^3.1.2" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA=="],
@@ -630,6 +675,8 @@
"node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="],
"nodemailer": ["nodemailer@9.0.6", "", {}, "sha512-IQUGFdhdGwI9+AWX+FpUt4DLmvFaOjTMEoneTIWX/RXxuy1TdenPwWrvFMSfLkPKl+HQEXWuSAxEMMbPYXtBmg=="],
"ohash": ["ohash@2.0.12", "", {}, "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
@@ -678,6 +725,10 @@
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
"pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
"pvutils": ["pvutils@1.2.0", "", {}, "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg=="],
"rc9": ["rc9@3.0.1", "", { "dependencies": { "defu": "^6.1.6", "destr": "^2.0.5" } }, "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ=="],
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
@@ -690,6 +741,8 @@
"readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="],
"reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
"remeda": ["remeda@2.33.4", "", {}, "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
@@ -742,6 +795,10 @@
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="],
"turbo": ["turbo@2.10.12", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.12", "@turbo/darwin-arm64": "2.10.12", "@turbo/linux-64": "2.10.12", "@turbo/linux-arm64": "2.10.12", "@turbo/windows-64": "2.10.12", "@turbo/windows-arm64": "2.10.12" }, "bin": { "turbo": "bin/turbo" } }, "sha512-AswgMPnpOoaVZHrrSBejETzEbuIA69OVGwfkHwfrY0A23VjWXBANzgq9+OymWOHAIArB7D1+1z498WY8fGg1Jw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
@@ -768,6 +825,8 @@
"@better-auth/core/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@better-auth/passkey/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.10.0", "", { "dependencies": { "@prisma/debug": "7.10.0" } }, "sha512-0bra1LFYi8xNw0yqV62bHJNQk4BKleOngZiqPWIQc7a3+9q6rqsnqJ15BuepP8943PFMvtmgnP52juZsyYkA6w=="],
"@prisma/fetch-engine/@prisma/get-platform": ["@prisma/get-platform@7.10.0", "", { "dependencies": { "@prisma/debug": "7.10.0" } }, "sha512-0bra1LFYi8xNw0yqV62bHJNQk4BKleOngZiqPWIQc7a3+9q6rqsnqJ15BuepP8943PFMvtmgnP52juZsyYkA6w=="],
@@ -795,5 +854,7 @@
"pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
}
}