backend-refactoring #1
@@ -1,6 +1,21 @@
|
|||||||
# PostgreSQL connection string
|
# PostgreSQL connection string
|
||||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/gruperly?schema=public"
|
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/gruperly?schema=public"
|
||||||
|
|
||||||
|
# Frontend origin (CORS + CSRF)
|
||||||
|
WEB_URL="http://localhost:6173"
|
||||||
|
|
||||||
# Better Auth
|
# Better Auth
|
||||||
BETTER_AUTH_SECRET="replace-with-a-strong-secret"
|
BETTER_AUTH_SECRET="replace-with-a-strong-secret"
|
||||||
BETTER_AUTH_URL="http://localhost:4000"
|
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"
|
"db:studio": "prisma studio"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@better-auth/passkey": "^1.7.2",
|
||||||
"@gruperly/shared": "workspace:*",
|
"@gruperly/shared": "workspace:*",
|
||||||
"@hono/zod-validator": "^0.8.0",
|
"@hono/zod-validator": "^0.8.0",
|
||||||
"@prisma/adapter-pg": "^7.10.0",
|
"@prisma/adapter-pg": "^7.10.0",
|
||||||
"@prisma/client": "^7.10.0",
|
"@prisma/client": "^7.10.0",
|
||||||
"better-auth": "^1.1.0",
|
"better-auth": "1.7.2",
|
||||||
"hono": "^4.6.0"
|
"hono": "^4.6.0",
|
||||||
|
"nodemailer": "^9.0.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.0.0",
|
"@types/bun": "^1.0.0",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
"prisma": "^7.10.0",
|
"prisma": "^7.10.0",
|
||||||
"typescript": "^5.7.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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
groupsMember Member[]
|
passkeys Passkey[]
|
||||||
groupsOwner Group[] @relation("OwnerGroups")
|
groupsMember GroupMember[]
|
||||||
waitlist WaitlistEntry[]
|
groupsOwner Group[] @relation("OwnerGroups")
|
||||||
|
orgMemberships Member[]
|
||||||
|
orgInvitations Invitation[] @relation("InvitedBy")
|
||||||
|
waitlist WaitlistEntry[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Account {
|
model Account {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
providerId String
|
providerId String
|
||||||
providerAccountId String
|
accountId String
|
||||||
refreshToken String?
|
issuer String
|
||||||
accessToken String?
|
accessToken String?
|
||||||
|
refreshToken String?
|
||||||
|
idToken String?
|
||||||
accessTokenExpiresAt DateTime?
|
accessTokenExpiresAt DateTime?
|
||||||
refreshTokenExpiresAt DateTime?
|
refreshTokenExpiresAt DateTime?
|
||||||
scope String?
|
scope String?
|
||||||
idToken String?
|
password String?
|
||||||
password String?
|
createdAt DateTime @default(now())
|
||||||
createdAt DateTime @default(now())
|
updatedAt DateTime @updatedAt
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([providerId, providerAccountId])
|
@@unique([issuer, accountId])
|
||||||
|
@@index([userId])
|
||||||
@@map("accounts")
|
@@map("accounts")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Session {
|
model Session {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
token String @unique
|
token String @unique
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
ipAddress String?
|
ipAddress String?
|
||||||
userAgent String?
|
userAgent String?
|
||||||
createdAt DateTime @default(now())
|
activeOrganizationId String?
|
||||||
updatedAt DateTime @updatedAt
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@ -76,6 +82,73 @@ model Verification {
|
|||||||
@@map("verifications")
|
@@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
|
// Domain models
|
||||||
model Group {
|
model Group {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
@@ -85,26 +158,26 @@ model Group {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
owner User @relation("OwnerGroups", fields: [createdById], references: [id], onDelete: Cascade)
|
owner User @relation("OwnerGroups", fields: [createdById], references: [id], onDelete: Cascade)
|
||||||
members Member[]
|
members GroupMember[]
|
||||||
students Student[]
|
students Student[]
|
||||||
payments Payment[]
|
payments Payment[]
|
||||||
|
|
||||||
@@map("groups")
|
@@map("groups")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Member {
|
model GroupMember {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
groupId String
|
groupId String
|
||||||
userId String
|
userId String
|
||||||
role Role @default(MEMBER) // OWNER, ADMIN, MEMBER
|
role Role @default(MEMBER) // OWNER, ADMIN, MEMBER
|
||||||
joinedAt DateTime @default(now())
|
joinedAt DateTime @default(now())
|
||||||
|
|
||||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([groupId, userId])
|
@@unique([groupId, userId])
|
||||||
@@map("members")
|
@@map("group_members")
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Role {
|
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 { Hono } from 'hono'
|
||||||
import { cors } from 'hono/cors'
|
import { cors } from 'hono/cors'
|
||||||
import { authRoutes } from './routes/auth'
|
import { auth } from './auth'
|
||||||
import { groupsRoutes } from './routes/groups'
|
import type { AppEnv } from './env'
|
||||||
import { studentsRoutes } from './routes/students'
|
import { listGroups, createGroupFromOrganization } from './routes/groups'
|
||||||
import { paymentsRoutes } from './routes/payments'
|
import { listPlaceholder as students } from './routes/students'
|
||||||
import { waitlistRoutes } from './routes/waitlist'
|
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.get('/', (c) => c.json({ name: 'Gruperly API', status: 'ok' }))
|
||||||
|
|
||||||
app.route('/auth', authRoutes)
|
app.get('/api/groups', listGroups)
|
||||||
app.route('/groups', groupsRoutes)
|
app.post('/api/groups/from-organization', createGroupFromOrganization)
|
||||||
app.route('/students', studentsRoutes)
|
app.get('/api/students', students)
|
||||||
app.route('/payments', paymentsRoutes)
|
app.get('/api/payments', payments)
|
||||||
app.route('/waitlist', waitlistRoutes)
|
app.get('/api/waitlist', waitlist)
|
||||||
|
|
||||||
export default app
|
export default app
|
||||||
|
|
||||||
|
|||||||
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()
|
export function listPlaceholder(c: Context) {
|
||||||
|
return c.json([])
|
||||||
paymentsRoutes.get('/', (c) => c.json([]))
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Hono } from 'hono'
|
import type { Context } from 'hono'
|
||||||
|
|
||||||
export const studentsRoutes = new Hono()
|
export function listPlaceholder(c: Context) {
|
||||||
|
return c.json([])
|
||||||
studentsRoutes.get('/', (c) => c.json([]))
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Hono } from 'hono'
|
import type { Context } from 'hono'
|
||||||
|
|
||||||
export const waitlistRoutes = new Hono()
|
export function listPlaceholder(c: Context) {
|
||||||
|
return c.json([])
|
||||||
waitlistRoutes.get('/', (c) => c.json([]))
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"types": ["bun"],
|
"types": ["bun"],
|
||||||
|
"declaration": false,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@gruperly/shared": ["../../packages/shared/src/index.ts"]
|
"@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"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@better-auth/passkey": "^1.7.2",
|
||||||
"@gruperly/shared": "workspace:*",
|
"@gruperly/shared": "workspace:*",
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@hookform/resolvers": "^3.9.0",
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
"@tanstack/react-router": "^1.90.0",
|
"@tanstack/react-router": "^1.90.0",
|
||||||
|
"better-auth": "1.7.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.34.0",
|
"lucide-react": "^1.34.0",
|
||||||
"react": "^19.0.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 { Outlet } from '@tanstack/react-router'
|
||||||
import { Bell } from 'lucide-react'
|
import { Header } from './Header'
|
||||||
import { Logo, LogoIcon } from '../brand'
|
|
||||||
import { Avatar, Button } from '../ui'
|
|
||||||
import { Sidebar } from './Sidebar'
|
import { Sidebar } from './Sidebar'
|
||||||
import { BottomNav } from './BottomNav'
|
import { BottomNav } from './BottomNav'
|
||||||
|
|
||||||
export function RootLayout() {
|
export function RootLayout() {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex min-h-dvh w-full max-w-6xl gap-6 lg:px-6">
|
<div className="min-h-dvh w-full">
|
||||||
<Sidebar />
|
<Header />
|
||||||
|
|
||||||
<div className="flex min-h-dvh w-full flex-col lg:min-w-0">
|
<div className="mx-auto flex w-full max-w-7xl gap-6 lg:px-6">
|
||||||
<header className="sticky top-0 z-30 border-b border-border bg-background/90 backdrop-blur">
|
<Sidebar />
|
||||||
<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>
|
|
||||||
|
|
||||||
<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 />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<BottomNav />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<BottomNav />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@ export function Sidebar() {
|
|||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
return (
|
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">
|
<a href="/" className="mb-8 flex items-center gap-2 px-1" aria-label="Gruperly inicio">
|
||||||
<LogoIcon className="size-8" />
|
<LogoIcon className="size-8" />
|
||||||
<Logo className="text-lg tracking-tight" />
|
<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 { RootLayout } from './RootLayout'
|
||||||
|
export { Header } from './Header'
|
||||||
export { Sidebar } from './Sidebar'
|
export { Sidebar } from './Sidebar'
|
||||||
export { BottomNav } from './BottomNav'
|
export { BottomNav } from './BottomNav'
|
||||||
export { NAV_ITEMS, type NavItem } from './nav-items'
|
export { NAV_ITEMS, type NavItem } from './nav-items'
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
export { Avatar, type AvatarProps } from './avatar'
|
export { Avatar, type AvatarProps } from './avatar'
|
||||||
export { Button, type ButtonProps, type ButtonVariant, type ButtonSize } from './button'
|
export { Button, type ButtonProps, type ButtonVariant, type ButtonSize } from './button'
|
||||||
export { Badge, type BadgeProps, type BadgeVariant } from './badge'
|
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 */
|
/* Border radius por defecto */
|
||||||
--radius-xl: 0.75rem;
|
--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 {
|
@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 ReactDOM from 'react-dom/client'
|
||||||
import { RouterProvider } from '@tanstack/react-router'
|
import { RouterProvider } from '@tanstack/react-router'
|
||||||
import { router } from './router'
|
import { router } from './router'
|
||||||
|
import { AuthProvider } from './context/AuthProvider'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<RouterProvider router={router} />
|
<AuthProvider>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</AuthProvider>
|
||||||
</React.StrictMode>,
|
</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 { RootLayout } from './components/layout'
|
||||||
import { HomeView } from './routes/home'
|
import { HomeView } from './routes/home'
|
||||||
import { GroupsView } from './routes/groups'
|
import { GroupsView } from './routes/groups'
|
||||||
import { PaymentsView } from './routes/payments'
|
import { PaymentsView } from './routes/payments'
|
||||||
import { SettingsView } from './routes/settings'
|
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({
|
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,
|
component: RootLayout,
|
||||||
})
|
})
|
||||||
|
|
||||||
const indexRoute = createRoute({
|
const indexRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => appLayoutRoute,
|
||||||
path: '/',
|
path: '/',
|
||||||
component: HomeView,
|
component: HomeView,
|
||||||
})
|
})
|
||||||
|
|
||||||
const groupsRoute = createRoute({
|
const groupsRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => appLayoutRoute,
|
||||||
path: '/groups',
|
path: '/groups',
|
||||||
component: GroupsView,
|
component: GroupsView,
|
||||||
})
|
})
|
||||||
|
|
||||||
const paymentsRoute = createRoute({
|
const paymentsRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => appLayoutRoute,
|
||||||
path: '/payments',
|
path: '/payments',
|
||||||
component: PaymentsView,
|
component: PaymentsView,
|
||||||
})
|
})
|
||||||
|
|
||||||
const settingsRoute = createRoute({
|
const settingsRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => appLayoutRoute,
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
component: SettingsView,
|
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([
|
const routeTree = rootRoute.addChildren([
|
||||||
indexRoute,
|
loginRoute,
|
||||||
groupsRoute,
|
signupRoute,
|
||||||
paymentsRoute,
|
verifyEmailRoute,
|
||||||
settingsRoute,
|
appLayoutRoute.addChildren([
|
||||||
|
indexRoute,
|
||||||
|
groupsRoute,
|
||||||
|
paymentsRoute,
|
||||||
|
settingsRoute,
|
||||||
|
securityRoute,
|
||||||
|
organizationsRoute,
|
||||||
|
profileRoute,
|
||||||
|
]),
|
||||||
])
|
])
|
||||||
|
|
||||||
export const router = createRouter({ routeTree })
|
export const router = createRouter({ routeTree })
|
||||||
|
|||||||
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() {
|
export function SettingsView() {
|
||||||
|
const handleSignOut = async () => {
|
||||||
|
await signOut({
|
||||||
|
fetchOptions: { headers: { 'Cache-Control': 'no-cache' } },
|
||||||
|
})
|
||||||
|
window.location.assign('/login')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section className="space-y-6">
|
||||||
<h1 className="text-2xl font-bold text-primary">Ajustes</h1>
|
<div>
|
||||||
<p className="mt-2 text-sm text-foreground/60">Configura tu cuenta.</p>
|
<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>
|
</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"],
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
"declaration": false,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@gruperly/shared": ["../../packages/shared/src/index.ts"]
|
"@gruperly/shared": ["../../packages/shared/src/index.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
79
bun.lock
79
bun.lock
@@ -12,15 +12,18 @@
|
|||||||
"name": "@gruperly/api",
|
"name": "@gruperly/api",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@better-auth/passkey": "^1.7.2",
|
||||||
"@gruperly/shared": "workspace:*",
|
"@gruperly/shared": "workspace:*",
|
||||||
"@hono/zod-validator": "^0.8.0",
|
"@hono/zod-validator": "^0.8.0",
|
||||||
"@prisma/adapter-pg": "^7.10.0",
|
"@prisma/adapter-pg": "^7.10.0",
|
||||||
"@prisma/client": "^7.10.0",
|
"@prisma/client": "^7.10.0",
|
||||||
"better-auth": "^1.1.0",
|
"better-auth": "1.7.2",
|
||||||
"hono": "^4.6.0",
|
"hono": "^4.6.0",
|
||||||
|
"nodemailer": "^9.0.6",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.0.0",
|
"@types/bun": "^1.0.0",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
"prisma": "^7.10.0",
|
"prisma": "^7.10.0",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
},
|
},
|
||||||
@@ -29,10 +32,12 @@
|
|||||||
"name": "@gruperly/web",
|
"name": "@gruperly/web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@better-auth/passkey": "^1.7.2",
|
||||||
"@gruperly/shared": "workspace:*",
|
"@gruperly/shared": "workspace:*",
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@hookform/resolvers": "^3.9.0",
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
"@tanstack/react-router": "^1.90.0",
|
"@tanstack/react-router": "^1.90.0",
|
||||||
|
"better-auth": "1.7.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.34.0",
|
"lucide-react": "^1.34.0",
|
||||||
"react": "^19.0.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=="],
|
"@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=="],
|
"@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"],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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/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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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/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/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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"ohash": ["ohash@2.0.12", "", {}, "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw=="],
|
||||||
|
|
||||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||||
@@ -678,6 +725,10 @@
|
|||||||
|
|
||||||
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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/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/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=="],
|
"@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=="],
|
"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=="],
|
"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