feat: basic initial UI layout for mobile and desktop
This commit is contained in:
@@ -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>"
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
191
apps/api/prisma/migrations/20260904220856_init_db/migration.sql
Normal file
191
apps/api/prisma/migrations/20260904220856_init_db/migration.sql
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
3
apps/api/prisma/migrations/migration_lock.toml
Normal file
3
apps/api/prisma/migrations/migration_lock.toml
Normal 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"
|
||||
@@ -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
63
apps/api/src/auth.ts
Normal 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
10
apps/api/src/env.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
94
apps/api/src/lib/email.ts
Normal 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()
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Hono } from 'hono'
|
||||
|
||||
export const authRoutes = new Hono()
|
||||
|
||||
authRoutes.get('/session', (c) => c.json({ message: 'placeholder' }))
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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([])
|
||||
}
|
||||
@@ -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([])
|
||||
}
|
||||
@@ -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([])
|
||||
}
|
||||
@@ -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
1
apps/web/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_URL=http://localhost:4000
|
||||
@@ -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",
|
||||
|
||||
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" />
|
||||
@@ -4,6 +4,7 @@
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "bundler",
|
||||
"declaration": false,
|
||||
"paths": {
|
||||
"@gruperly/shared": ["../../packages/shared/src/index.ts"]
|
||||
}
|
||||
|
||||
79
bun.lock
79
bun.lock
@@ -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=="],
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user