From a322ee6a43f46affaf0fdb4cd3b85cf3080cfef2 Mon Sep 17 00:00:00 2001 From: Vinicius Garcia Date: Thu, 8 Oct 2026 18:58:55 +0000 Subject: [PATCH] spike(auth): prova de conceito do Better Auth (nao mergear em develop) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VDxbrneS3TEAUmdgVmkKpQ --- .env.example | 12 + SPIKE-BETTER-AUTH.md | 25 + drizzle.config.ts | 2 +- drizzle/0005_better_auth_spike.sql | 91 ++ drizzle/meta/0005_snapshot.json | 1937 ++++++++++++++++++++++++ drizzle/meta/_journal.json | 9 +- package-lock.json | 464 +++++- package.json | 1 + src/app/api/auth/[...all]/route.ts | 6 + src/app/spike/accept/[id]/accept.tsx | 25 + src/app/spike/accept/[id]/page.tsx | 15 + src/app/spike/layout.tsx | 6 + src/app/spike/org-panel.tsx | 58 + src/app/spike/page.tsx | 33 + src/app/spike/sign-in/page.tsx | 5 + src/app/spike/sign-in/sign-in-form.tsx | 60 + src/app/spike/tenant.ts | 6 + src/db/auth-schema.ts | 178 +++ src/lib/ba/auth-client.ts | 6 + src/server/ba/auth.smoke.test.ts | 77 + src/server/ba/auth.ts | 86 ++ src/server/ba/context.test.ts | 16 + src/server/ba/context.ts | 54 + src/server/ba/mail.ts | 17 + 24 files changed, 3142 insertions(+), 47 deletions(-) create mode 100644 SPIKE-BETTER-AUTH.md create mode 100644 drizzle/0005_better_auth_spike.sql create mode 100644 drizzle/meta/0005_snapshot.json create mode 100644 src/app/api/auth/[...all]/route.ts create mode 100644 src/app/spike/accept/[id]/accept.tsx create mode 100644 src/app/spike/accept/[id]/page.tsx create mode 100644 src/app/spike/layout.tsx create mode 100644 src/app/spike/org-panel.tsx create mode 100644 src/app/spike/page.tsx create mode 100644 src/app/spike/sign-in/page.tsx create mode 100644 src/app/spike/sign-in/sign-in-form.tsx create mode 100644 src/app/spike/tenant.ts create mode 100644 src/db/auth-schema.ts create mode 100644 src/lib/ba/auth-client.ts create mode 100644 src/server/ba/auth.smoke.test.ts create mode 100644 src/server/ba/auth.ts create mode 100644 src/server/ba/context.test.ts create mode 100644 src/server/ba/context.ts create mode 100644 src/server/ba/mail.ts diff --git a/.env.example b/.env.example index 2f99090..c73255c 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,15 @@ CLERK_SECRET_KEY=sk_test_xxxxxxxx # Clerk webhook: Dashboard > Webhooks > your endpoint > Signing Secret (whsec_...) CLERK_WEBHOOK_SIGNING_SECRET=whsec_xxxxxxxx + +# SPIKE (ADR-033, branch spike/better-auth only) +BETTER_AUTH_SECRET=generate-with-openssl-rand-base64-32 +BETTER_AUTH_URL=http://localhost:3000 +# Optional: without RESEND_API_KEY the e-mails (OTP, invitation) are printed in the server console +RESEND_API_KEY= +MAIL_FROM= +# Optional social login (redirect: http://localhost:3000/api/auth/callback/) +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= diff --git a/SPIKE-BETTER-AUTH.md b/SPIKE-BETTER-AUTH.md new file mode 100644 index 0000000..c266cd9 --- /dev/null +++ b/SPIKE-BETTER-AUTH.md @@ -0,0 +1,25 @@ +# Spike: Better Auth (ADR-033) — NÃO MERGEAR EM `develop` + +Branch de investigação. Nada aqui vai para produção: o merge em `develop` faz deploy e a migration `0005_better_auth_spike` não deve rodar no Neon `production`. + +## O que foi montado +- `src/db/auth-schema.ts` + `drizzle/0005_better_auth_spike.sql`: tabelas `ba_*` (user, session, account, verification, organization, member, invitation), geradas por `npx auth@latest generate` e só renomeadas com prefixo. Tabelas públicas do app intocadas. +- `src/server/ba/auth.ts`: `createAuth()`/`getAuth()` — Drizzle adapter `pg`, `emailOTP`, `organization` (convite por e-mail), Google/GitHub condicionais por env, hook que ativa a primeira organização no login, `nextCookies()` por último. +- `src/server/ba/context.ts`: `requireBaWorkspaceContext()` com o mesmo contrato de `requireWorkspaceContext()`; espelha em `users`/`workspaces` reutilizando os upserts de `clerk-sync.ts` (ids com prefixo `ba:` nas colunas `clerk_*`, para não migrar tabelas públicas no spike). +- `src/app/api/auth/[...all]/route.ts`, `src/lib/ba/auth-client.ts`, páginas `/spike`, `/spike/sign-in`, `/spike/accept/[id]`. +- Testes: `src/server/ba/auth.smoke.test.ts` (PGlite, Better Auth real) e `context.test.ts`. + +## Como testar localmente (banco `development` do Neon!) +1. No `.env.local`: `BETTER_AUTH_SECRET` (`openssl rand -base64 32`), `BETTER_AUTH_URL=http://localhost:3000`; opcionais `RESEND_API_KEY`, `GOOGLE_*`, `GITHUB_*` (callback `http://localhost:3000/api/auth/callback/`). +2. Conferir que `DATABASE_URL` aponta para `development`; `npm run db:migrate` (ou `npx drizzle-kit migrate`). +3. `npm run dev`; abrir `/spike/sign-in`. Sem `RESEND_API_KEY`, o código e o link de convite saem no console do servidor. + +## Critérios (ADR-033) +| | Critério | Estado | +|---|---|---| +| a | Login por código + Google + GitHub | Código: validado em teste (PGlite). Google/GitHub: configurado, **não testado** (precisa de credenciais OAuth) | +| b | Funciona com neon-http | Pelo código do adapter, `pg` só usa transação com `transaction: true` (desligado) → provável OK. **Não testado contra Neon** | +| c | Organizações ↔ workspaces, admin/member | Validado em teste (owner/admin → admin; member). Ponte `requireBaWorkspaceContext()` só typecheck + teste de papel; **rodar no app** | +| d | Convite por e-mail aceito por 2º usuário | Fluxo validado em teste (inclui recusa de e-mail diferente). Envio real via Resend **não testado** | +| e | Sessão lida em `getTenant()` sem mexer no resto | Contrato igual; páginas `/spike` usam a ponte. **Rodar no app** | +| f | Estimativa de migração | Ver ADR-033 (atualizado após o teste manual) | diff --git a/drizzle.config.ts b/drizzle.config.ts index 0c5a6d4..0ba792b 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -8,7 +8,7 @@ try { export default defineConfig({ dialect: "postgresql", - schema: "./src/db/schema.ts", + schema: ["./src/db/schema.ts", "./src/db/auth-schema.ts"], out: "./drizzle", dbCredentials: { url: process.env.DATABASE_URL ?? "" }, strict: true, diff --git a/drizzle/0005_better_auth_spike.sql b/drizzle/0005_better_auth_spike.sql new file mode 100644 index 0000000..df65fb6 --- /dev/null +++ b/drizzle/0005_better_auth_spike.sql @@ -0,0 +1,91 @@ +CREATE TABLE "ba_account" ( + "id" text PRIMARY KEY NOT NULL, + "account_id" text NOT NULL, + "provider_id" text NOT NULL, + "user_id" text NOT NULL, + "access_token" text, + "refresh_token" text, + "id_token" text, + "access_token_expires_at" timestamp, + "refresh_token_expires_at" timestamp, + "scope" text, + "password" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp NOT NULL +); +--> statement-breakpoint +CREATE TABLE "ba_invitation" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "email" text NOT NULL, + "role" text, + "status" text DEFAULT 'pending' NOT NULL, + "expires_at" timestamp NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "inviter_id" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "ba_member" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "user_id" text NOT NULL, + "role" text DEFAULT 'member' NOT NULL, + "created_at" timestamp NOT NULL +); +--> statement-breakpoint +CREATE TABLE "ba_organization" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "slug" text NOT NULL, + "logo" text, + "created_at" timestamp NOT NULL, + "metadata" text, + CONSTRAINT "ba_organization_slug_unique" UNIQUE("slug") +); +--> statement-breakpoint +CREATE TABLE "ba_session" ( + "id" text PRIMARY KEY NOT NULL, + "expires_at" timestamp NOT NULL, + "token" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp NOT NULL, + "ip_address" text, + "user_agent" text, + "user_id" text NOT NULL, + "active_organization_id" text, + CONSTRAINT "ba_session_token_unique" UNIQUE("token") +); +--> statement-breakpoint +CREATE TABLE "ba_user" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL, + "email_verified" boolean DEFAULT false NOT NULL, + "image" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "ba_user_email_unique" UNIQUE("email") +); +--> statement-breakpoint +CREATE TABLE "ba_verification" ( + "id" text PRIMARY KEY NOT NULL, + "identifier" text NOT NULL, + "value" text NOT NULL, + "expires_at" timestamp NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "ba_account" ADD CONSTRAINT "ba_account_user_id_ba_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."ba_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ba_invitation" ADD CONSTRAINT "ba_invitation_organization_id_ba_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."ba_organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ba_invitation" ADD CONSTRAINT "ba_invitation_inviter_id_ba_user_id_fk" FOREIGN KEY ("inviter_id") REFERENCES "public"."ba_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ba_member" ADD CONSTRAINT "ba_member_organization_id_ba_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."ba_organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ba_member" ADD CONSTRAINT "ba_member_user_id_ba_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."ba_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "ba_session" ADD CONSTRAINT "ba_session_user_id_ba_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."ba_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "account_userId_idx" ON "ba_account" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "invitation_organizationId_idx" ON "ba_invitation" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "invitation_email_idx" ON "ba_invitation" USING btree ("email");--> statement-breakpoint +CREATE INDEX "member_organizationId_idx" ON "ba_member" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "member_userId_idx" ON "ba_member" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "session_userId_idx" ON "ba_session" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "verification_identifier_idx" ON "ba_verification" USING btree ("identifier"); \ No newline at end of file diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..371c453 --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,1937 @@ +{ + "id": "3f76dd64-34e2-40da-9976-76d625a66599", + "prevId": "68e2ba09-141c-4b07-a6c4-c41e1428e55d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_ws_name_uq": { + "name": "organizations_ws_name_uq", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organizations_workspace_id_workspaces_id_fk": { + "name": "organizations_workspace_id_workspaces_id_fk", + "tableFrom": "organizations", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_members": { + "name": "project_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_members_project_user_uq": { + "name": "project_members_project_user_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_members_ws_user_idx": { + "name": "project_members_ws_user_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_members_workspace_id_workspaces_id_fk": { + "name": "project_members_workspace_id_workspaces_id_fk", + "tableFrom": "project_members", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_project_id_projects_id_fk": { + "name": "project_members_project_id_projects_id_fk", + "tableFrom": "project_members", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_user_id_users_id_fk": { + "name": "project_members_user_id_users_id_fk", + "tableFrom": "project_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#3B82F6'" + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_org_name_uq": { + "name": "projects_org_name_uq", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_ws_idx": { + "name": "projects_ws_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_workspace_id_workspaces_id_fk": { + "name": "projects_workspace_id_workspaces_id_fk", + "tableFrom": "projects", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_organization_id_organizations_id_fk": { + "name": "projects_organization_id_organizations_id_fk", + "tableFrom": "projects", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tags": { + "name": "tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#6B7280'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tags_ws_name_uq": { + "name": "tags_ws_name_uq", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tags_workspace_id_workspaces_id_fk": { + "name": "tags_workspace_id_workspaces_id_fk", + "tableFrom": "tags", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_completed": { + "name": "is_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "tasks_project_name_uq": { + "name": "tasks_project_name_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_ws_idx": { + "name": "tasks_ws_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_workspace_id_workspaces_id_fk": { + "name": "tasks_workspace_id_workspaces_id_fk", + "tableFrom": "tasks", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.time_entries": { + "name": "time_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_billable": { + "name": "is_billable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "time_entries_one_running_per_user_uq": { + "name": "time_entries_one_running_per_user_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"time_entries\".\"ended_at\" is null and \"time_entries\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "time_entries_ws_user_start_idx": { + "name": "time_entries_ws_user_start_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "time_entries_ws_project_start_idx": { + "name": "time_entries_ws_project_start_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "time_entries_workspace_id_workspaces_id_fk": { + "name": "time_entries_workspace_id_workspaces_id_fk", + "tableFrom": "time_entries", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "time_entries_user_id_users_id_fk": { + "name": "time_entries_user_id_users_id_fk", + "tableFrom": "time_entries", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "time_entries_project_id_projects_id_fk": { + "name": "time_entries_project_id_projects_id_fk", + "tableFrom": "time_entries", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "time_entries_task_id_tasks_id_fk": { + "name": "time_entries_task_id_tasks_id_fk", + "tableFrom": "time_entries", + "tableTo": "tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "time_entries_end_after_start": { + "name": "time_entries_end_after_start", + "value": "\"time_entries\".\"ended_at\" is null or \"time_entries\".\"ended_at\" > \"time_entries\".\"started_at\"" + } + }, + "isRLSEnabled": false + }, + "public.time_entry_audit": { + "name": "time_entry_audit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "time_entry_id": { + "name": "time_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "time_entry_audit_entry_idx": { + "name": "time_entry_audit_entry_idx", + "columns": [ + { + "expression": "time_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "time_entry_audit_ws_idx": { + "name": "time_entry_audit_ws_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "time_entry_audit_workspace_id_workspaces_id_fk": { + "name": "time_entry_audit_workspace_id_workspaces_id_fk", + "tableFrom": "time_entry_audit", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "time_entry_audit_time_entry_id_time_entries_id_fk": { + "name": "time_entry_audit_time_entry_id_time_entries_id_fk", + "tableFrom": "time_entry_audit", + "tableTo": "time_entries", + "columnsFrom": [ + "time_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "time_entry_audit_actor_user_id_users_id_fk": { + "name": "time_entry_audit_actor_user_id_users_id_fk", + "tableFrom": "time_entry_audit", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.time_entry_tags": { + "name": "time_entry_tags", + "schema": "", + "columns": { + "time_entry_id": { + "name": "time_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "time_entry_tags_tag_idx": { + "name": "time_entry_tags_tag_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "time_entry_tags_time_entry_id_time_entries_id_fk": { + "name": "time_entry_tags_time_entry_id_time_entries_id_fk", + "tableFrom": "time_entry_tags", + "tableTo": "time_entries", + "columnsFrom": [ + "time_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "time_entry_tags_tag_id_tags_id_fk": { + "name": "time_entry_tags_tag_id_tags_id_fk", + "tableFrom": "time_entry_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "time_entry_tags_time_entry_id_tag_id_pk": { + "name": "time_entry_tags_time_entry_id_tag_id_pk", + "columns": [ + "time_entry_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "clerk_id": { + "name": "clerk_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'America/Recife'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_clerk_id_unique": { + "name": "users_clerk_id_unique", + "nullsNotDistinct": false, + "columns": [ + "clerk_id" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_members": { + "name": "workspace_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "workspace_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_members_ws_user_uq": { + "name": "workspace_members_ws_user_uq", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_members_workspace_id_workspaces_id_fk": { + "name": "workspace_members_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_members", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_members_user_id_users_id_fk": { + "name": "workspace_members_user_id_users_id_fk", + "tableFrom": "workspace_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "clerk_org_id": { + "name": "clerk_org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "report_timezone": { + "name": "report_timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspaces_clerk_org_id_unique": { + "name": "workspaces_clerk_org_id_unique", + "nullsNotDistinct": false, + "columns": [ + "clerk_org_id" + ] + }, + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ba_account": { + "name": "ba_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ba_account_user_id_ba_user_id_fk": { + "name": "ba_account_user_id_ba_user_id_fk", + "tableFrom": "ba_account", + "tableTo": "ba_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ba_invitation": { + "name": "ba_invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitation_organizationId_idx": { + "name": "invitation_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ba_invitation_organization_id_ba_organization_id_fk": { + "name": "ba_invitation_organization_id_ba_organization_id_fk", + "tableFrom": "ba_invitation", + "tableTo": "ba_organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ba_invitation_inviter_id_ba_user_id_fk": { + "name": "ba_invitation_inviter_id_ba_user_id_fk", + "tableFrom": "ba_invitation", + "tableTo": "ba_user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ba_member": { + "name": "ba_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "member_organizationId_idx": { + "name": "member_organizationId_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_userId_idx": { + "name": "member_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ba_member_organization_id_ba_organization_id_fk": { + "name": "ba_member_organization_id_ba_organization_id_fk", + "tableFrom": "ba_member", + "tableTo": "ba_organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ba_member_user_id_ba_user_id_fk": { + "name": "ba_member_user_id_ba_user_id_fk", + "tableFrom": "ba_member", + "tableTo": "ba_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ba_organization": { + "name": "ba_organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ba_organization_slug_unique": { + "name": "ba_organization_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ba_session": { + "name": "ba_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ba_session_user_id_ba_user_id_fk": { + "name": "ba_session_user_id_ba_user_id_fk", + "tableFrom": "ba_session", + "tableTo": "ba_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ba_session_token_unique": { + "name": "ba_session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ba_user": { + "name": "ba_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ba_user_email_unique": { + "name": "ba_user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ba_verification": { + "name": "ba_verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.workspace_role": { + "name": "workspace_role", + "schema": "public", + "values": [ + "admin", + "member" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index c0c05b8..9cd8a2c 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1791560000000, "tag": "0004_time_entry_audit", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1791485860508, + "tag": "0005_better_auth_spike", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 93cc65d..3ac8df3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@radix-ui/react-select": "^2.3.8", "@radix-ui/react-slot": "^1.4.0", "@react-pdf/renderer": "^4.9.0", + "better-auth": "^1.7.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-orm": "^0.45.3", @@ -306,6 +307,147 @@ "node": ">=6.9.0" } }, + "node_modules/@better-auth/core": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.7.7.tgz", + "integrity": "sha512-srgFzHEjB2WWlokFz1i4BfG41sNhsA105Pukgqu6RrEh5A+LSkzf1mCHydVipkjt9l5qswtBMi7IL6ouHbZL4Q==", + "license": "MIT", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.41.1", + "@standard-schema/spec": "^1.1.0", + "zod": "^4.5.4" + }, + "peerDependencies": { + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.2", + "@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" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@better-auth/drizzle-adapter": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.7.7.tgz", + "integrity": "sha512-qon0U94PR5FQysuyGoAlGVQpIIiNg5dtDpwjE63sN/HjoRWZadZzHPcS9mn7JsJyQZK6iTNhd7LD0AaXt2f78w==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.7", + "@better-auth/utils": "0.4.2", + "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" + }, + "peerDependenciesMeta": { + "drizzle-orm": { + "optional": true + } + } + }, + "node_modules/@better-auth/kysely-adapter": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.7.7.tgz", + "integrity": "sha512-9FONOCgOcrQI9wJ5v1oDGIg9sT7pa9RjZZQ4fsCTudo8R87SrIGNUvUxDFTJhZvUblvFfGaIBR9Vb8LA+8bqLQ==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.7", + "@better-auth/utils": "0.4.2", + "kysely": "^0.28.17 || ^0.29.0" + }, + "peerDependenciesMeta": { + "kysely": { + "optional": true + } + } + }, + "node_modules/@better-auth/memory-adapter": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.7.7.tgz", + "integrity": "sha512-FqKFEe+b5tXXV0gRbyirca+qXgMpLOeJ8Wk19yykb/scQKy0WJ4k22WzEV3TTPctQV9DZhwCXPlDtQhnlOv5Gg==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.7", + "@better-auth/utils": "0.4.2" + } + }, + "node_modules/@better-auth/mongo-adapter": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.7.7.tgz", + "integrity": "sha512-ZnVDuRrXqbf0oHphrEN27oTImNH5NTO26dtWatq5cXyYAsH7goof9QIoTOmxTbBxUQahKBw9T6+9h6n+RtOE+g==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.7", + "@better-auth/utils": "0.4.2", + "mongodb": "^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "mongodb": { + "optional": true + } + } + }, + "node_modules/@better-auth/prisma-adapter": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.7.7.tgz", + "integrity": "sha512-4YcnrcVdvWiAZw0sZl63tWUClPXg5r3QsLn7C2sG7kKEBY06zOng0ibecyxRBixKq1zE1ceHuEUqez5Rhi+nQA==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.7", + "@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" + }, + "peerDependenciesMeta": { + "@prisma/client": { + "optional": true + }, + "prisma": { + "optional": true + } + } + }, + "node_modules/@better-auth/telemetry": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.7.7.tgz", + "integrity": "sha512-PaRA6i+kAioVYmza2gHPcdrxP0hAotph4KV9hft2GeNJq+AZXDiBu3FuLM9HxvcmiZeUT74Vufqd9ypmB8veTA==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.7.7", + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.2" + } + }, + "node_modules/@better-auth/utils": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.2.tgz", + "integrity": "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1" + } + }, + "node_modules/@better-auth/utils/node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@better-fetch/fetch": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.3.2.tgz", + "integrity": "sha512-Gs7n99b5tqUC6cQAPbV0uED3IraHB6xQbHLQ/C3l7ZFafHScOx9pQ+DYmP5blbLFShVWLqxNUlI9wi4xU/X+ow==", + "license": "MIT" + }, "node_modules/@clerk/backend": { "version": "3.23.0", "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-3.23.0.tgz", @@ -389,7 +531,7 @@ "version": "0.10.2", "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", - "dev": true, + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@electric-sql/pglite": { @@ -437,7 +579,7 @@ "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", "deprecated": "Merged into tsx: https://tsx.hirok.io", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "esbuild": "~0.18.20", @@ -822,7 +964,7 @@ "version": "0.18.20", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -861,7 +1003,7 @@ "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", "deprecated": "Merged into tsx: https://tsx.hirok.io", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", @@ -2144,7 +2286,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -2154,14 +2296,14 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -2448,11 +2590,20 @@ "node": ">=12.4.0" } }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@oxc-project/types": { "version": "0.152.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "funding": { @@ -3471,7 +3622,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true }, @@ -3488,6 +3639,12 @@ "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.23", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", @@ -3792,7 +3949,7 @@ "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/deep-eql": "*", @@ -3803,14 +3960,14 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -4480,7 +4637,7 @@ "version": "5.0.3", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.31", @@ -4508,7 +4665,7 @@ "version": "1.4.3", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" @@ -4518,7 +4675,7 @@ "version": "5.0.3", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://opencollective.com/vitest" @@ -4852,7 +5009,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -4953,6 +5110,172 @@ "node": ">=6.0.0" } }, + "node_modules/better-auth": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.7.7.tgz", + "integrity": "sha512-Zhgxi/c5ylmfB/sX+nwnBbsFf/j6k0L8DvL6CJXZSrXgxc5pPMC/e7j812zNd4L4B4ELmdURCPw3X8nAOUdcjw==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.7.7", + "@better-auth/drizzle-adapter": "1.7.7", + "@better-auth/kysely-adapter": "1.7.7", + "@better-auth/memory-adapter": "1.7.7", + "@better-auth/mongo-adapter": "1.7.7", + "@better-auth/prisma-adapter": "1.7.7", + "@better-auth/telemetry": "1.7.7", + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.2", + "@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.5.4" + }, + "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", + "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 || ^5.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@lynx-js/react": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "@tanstack/react-start": { + "optional": true + }, + "@tanstack/solid-start": { + "optional": true + }, + "drizzle-kit": { + "optional": true + }, + "drizzle-orm": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "next": { + "optional": true + }, + "pg": { + "optional": true + }, + "prisma": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vitest": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@noble/ciphers": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz", + "integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/better-auth/node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/better-call": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.4.0.tgz", + "integrity": "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/better-call/node_modules/@better-auth/utils": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.5.0.tgz", + "integrity": "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1" + } + }, + "node_modules/better-call/node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/bidi-js": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", @@ -5104,7 +5427,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/buffer-indexof-polyfill": { @@ -5205,7 +5528,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -5521,6 +5844,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -5569,7 +5898,7 @@ "version": "0.31.11", "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.11.tgz", "integrity": "sha512-YCYqxTLIB2OCqf6w9/Vef13baBVbwQIPcbT4Y56AfO6B9ajZhDhD8Jkveg/hJvT/iuMloKD/IYYkyMMNhr7Kqg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@drizzle-team/brocli": "^0.10.2", @@ -6027,7 +6356,7 @@ "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -6432,7 +6761,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/es-object-atoms": { @@ -6499,7 +6828,7 @@ "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -6961,7 +7290,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0" @@ -7010,7 +7339,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=12.0.0" @@ -7367,7 +7696,7 @@ "version": "4.14.3", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz", "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -8148,6 +8477,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-cookie": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", @@ -8304,6 +8642,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/kysely": { + "version": "0.29.6", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.6.tgz", + "integrity": "sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg==", + "license": "MIT", + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/language-subtag-registry": { "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", @@ -8927,6 +9274,21 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nanostores": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.5.5.tgz", + "integrity": "sha512-FixtE239Gl6Gz0ZGCK0WzvNwObNCIwVT+L7zQcf1QgcA6AzBxblzqe9OT88qiucNkhsjSOqiogSaF5jVbJiqSA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -9183,7 +9545,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", - "dev": true, + "devOptional": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" @@ -9711,7 +10073,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" @@ -9751,7 +10113,7 @@ "version": "1.2.12", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -9782,6 +10144,12 @@ "@rolldown/binding-win32-x64-msvc": "1.2.12" } }, + "node_modules/rou3": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.9.2.tgz", + "integrity": "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==", + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -9912,6 +10280,12 @@ "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", "license": "MIT" }, + "node_modules/set-cookie-parser": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.3.tgz", + "integrity": "sha512-xletSdAI5efyVJjsatBbzDkXl3M/Zmad3xfnQe7ZXuBB0yNwW7C2WFsNGjdRNfZunmNHQuX79xs/ACMTLCdcew==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -10126,7 +10500,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -10145,7 +10519,7 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", @@ -10173,7 +10547,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/stop-iteration-iterator": { @@ -10444,7 +10818,7 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.1.tgz", "integrity": "sha512-IAbQaPJIIdhVxi0mqy9lez1wEwje0UQf9F1vwtbelEG5Nbgn0nPOxCvU+mNeNS9gyJTAhrHp74CbUaSynQpZcQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=20.0.0" @@ -10454,7 +10828,7 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -10464,7 +10838,7 @@ "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -10481,7 +10855,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -10499,7 +10873,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -10598,7 +10972,7 @@ "version": "4.23.15", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "esbuild": "~0.28.0" @@ -11006,7 +11380,7 @@ "version": "8.3.3", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.3.tgz", "integrity": "sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -11085,7 +11459,7 @@ "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", - "dev": true, + "devOptional": true, "license": "MPL-2.0", "peer": true, "dependencies": { @@ -11358,7 +11732,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, + "devOptional": true, "license": "MIT", "peer": true, "engines": { @@ -11372,7 +11746,7 @@ "version": "8.5.29", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", - "dev": true, + "devOptional": true, "funding": [ { "type": "opencollective", @@ -11402,7 +11776,7 @@ "version": "5.0.3", "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", @@ -11485,7 +11859,7 @@ "version": "1.4.3", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" @@ -11495,7 +11869,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -11609,7 +11983,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "why-is-node-running": "cli.js" diff --git a/package.json b/package.json index 68f31d8..a071487 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@radix-ui/react-select": "^2.3.8", "@radix-ui/react-slot": "^1.4.0", "@react-pdf/renderer": "^4.9.0", + "better-auth": "^1.7.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-orm": "^0.45.3", diff --git a/src/app/api/auth/[...all]/route.ts b/src/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..e4462a8 --- /dev/null +++ b/src/app/api/auth/[...all]/route.ts @@ -0,0 +1,6 @@ +import { toNextJsHandler } from "better-auth/next-js"; +import { getAuth } from "@/server/ba/auth"; + +// SPIKE (ADR-033): lazy so that importing the route never needs env vars. +export const GET = (req: Request) => toNextJsHandler(getAuth()).GET(req); +export const POST = (req: Request) => toNextJsHandler(getAuth()).POST(req); diff --git a/src/app/spike/accept/[id]/accept.tsx b/src/app/spike/accept/[id]/accept.tsx new file mode 100644 index 0000000..1683cef --- /dev/null +++ b/src/app/spike/accept/[id]/accept.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { authClient } from "@/lib/ba/auth-client"; + +export function AcceptInvitation({ id }: { id: string }) { + const [msg, setMsg] = useState(null); + return ( +
+

Aceitar convite

+

Entre com o MESMO e-mail do convite (em /spike/sign-in) e depois aceite aqui.

+ + {msg ?

{msg}

: null} +
+ ); +} diff --git a/src/app/spike/accept/[id]/page.tsx b/src/app/spike/accept/[id]/page.tsx new file mode 100644 index 0000000..f55b054 --- /dev/null +++ b/src/app/spike/accept/[id]/page.tsx @@ -0,0 +1,15 @@ +import { Suspense } from "react"; +import { AcceptInvitation } from "./accept"; + +export default function AcceptPage({ params }: { params: Promise<{ id: string }> }) { + return ( + Carregando…

}> + +
+ ); +} + +async function Inner({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return ; +} diff --git a/src/app/spike/layout.tsx b/src/app/spike/layout.tsx new file mode 100644 index 0000000..cd7d29c --- /dev/null +++ b/src/app/spike/layout.tsx @@ -0,0 +1,6 @@ +// SPIKE (ADR-033): throwaway pages to exercise Better Auth. Never merged into `develop`. +export const metadata = { title: "Spike Better Auth", robots: { index: false } }; + +export default function SpikeLayout({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/src/app/spike/org-panel.tsx b/src/app/spike/org-panel.tsx new file mode 100644 index 0000000..3930b96 --- /dev/null +++ b/src/app/spike/org-panel.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { authClient } from "@/lib/ba/auth-client"; + +export function OrgPanel({ orgs, activeId }: { orgs: { id: string; name: string }[]; activeId: string | null }) { + const [name, setName] = useState(""); + const [inviteEmail, setInviteEmail] = useState(""); + const [role, setRole] = useState<"member" | "admin">("member"); + const [msg, setMsg] = useState(null); + + const reload = () => window.location.reload(); + + async function createOrg(e: React.FormEvent) { + e.preventDefault(); + const slug = `${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${Math.random().toString(36).slice(2, 6)}`; + const res = await authClient.organization.create({ name, slug }); + if (res.error) return setMsg(res.error.message ?? "Erro"); + await authClient.organization.setActive({ organizationId: res.data.id }); + reload(); + } + + async function invite(e: React.FormEvent) { + e.preventDefault(); + const res = await authClient.organization.inviteMember({ email: inviteEmail, role, organizationId: activeId ?? undefined }); + setMsg(res.error ? (res.error.message ?? "Erro") : `Convite enviado para ${inviteEmail}`); + } + + return ( +
+
+ {orgs.map((o) => ( + + ))} +
+
+ setName(e.target.value)} /> + +
+ {activeId ? ( +
+ setInviteEmail(e.target.value)} /> + + +
+ ) : null} + + {msg ?

{msg}

: null} +
+ ); +} diff --git a/src/app/spike/page.tsx b/src/app/spike/page.tsx new file mode 100644 index 0000000..8bb85e4 --- /dev/null +++ b/src/app/spike/page.tsx @@ -0,0 +1,33 @@ +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { Suspense } from "react"; +import { getTenant } from "./tenant"; +import { getAuth } from "@/server/ba/auth"; +import { OrgPanel } from "./org-panel"; + +export default function SpikePage() { + return ( + Carregando…

}> + +
+ ); +} + +async function SpikeContent() { + const session = await getAuth().api.getSession({ headers: await headers() }); + if (!session) redirect("/spike/sign-in"); + const orgs = await getAuth().api.listOrganizations({ headers: await headers() }); + const activeId = session.session.activeOrganizationId ?? null; + + // Criterion (e): the same contract the app uses today, resolved from the Better Auth session. + const ctx = activeId ? await getTenant().catch((e: unknown) => ({ error: String(e) })) : null; + + return ( +
+

Spike Better Auth

+

Logado como {session.user.email}

+ ({ id: o.id, name: o.name }))} activeId={activeId} /> +
{JSON.stringify(ctx, null, 2)}
+
+ ); +} diff --git a/src/app/spike/sign-in/page.tsx b/src/app/spike/sign-in/page.tsx new file mode 100644 index 0000000..5138ed1 --- /dev/null +++ b/src/app/spike/sign-in/page.tsx @@ -0,0 +1,5 @@ +import { SignInForm } from "./sign-in-form"; + +export default function SpikeSignInPage() { + return ; +} diff --git a/src/app/spike/sign-in/sign-in-form.tsx b/src/app/spike/sign-in/sign-in-form.tsx new file mode 100644 index 0000000..73e5793 --- /dev/null +++ b/src/app/spike/sign-in/sign-in-form.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { authClient } from "@/lib/ba/auth-client"; + +export function SignInForm() { + const [email, setEmail] = useState(""); + const [otp, setOtp] = useState(""); + const [step, setStep] = useState<"email" | "code">("email"); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function sendCode(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + setError(null); + const res = await authClient.emailOtp.sendVerificationOtp({ email, type: "sign-in" }); + setBusy(false); + if (res.error) setError(res.error.message ?? "Não foi possível enviar o código."); + else setStep("code"); + } + + async function verify(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + setError(null); + const res = await authClient.signIn.emailOtp({ email, otp }); + if (res.error) { + setBusy(false); + setError(res.error.message ?? "Código inválido."); + return; + } + window.location.assign("/spike"); + } + + return ( +
+

Entrar (spike Better Auth)

+ {step === "email" ? ( +
+ setEmail(e.target.value)} /> + +
+ ) : ( +
+

Código enviado para {email}. Sem RESEND_API_KEY, ele aparece no console do servidor.

+ setOtp(e.target.value)} /> + +
+ )} +
+ + +
+ {error ?

{error}

: null} +
+ ); +} diff --git a/src/app/spike/tenant.ts b/src/app/spike/tenant.ts new file mode 100644 index 0000000..5a83d23 --- /dev/null +++ b/src/app/spike/tenant.ts @@ -0,0 +1,6 @@ +import { requireBaWorkspaceContext } from "@/server/ba/context"; + +/** Criterion (e): proves the Clerk-free context plugs into the same shape `getTenant()` returns. */ +export async function getTenant() { + return requireBaWorkspaceContext(); +} diff --git a/src/db/auth-schema.ts b/src/db/auth-schema.ts new file mode 100644 index 0000000..5e3d1a8 --- /dev/null +++ b/src/db/auth-schema.ts @@ -0,0 +1,178 @@ +/** + * SPIKE (ADR-033): Better Auth tables, generated with `npx auth@latest generate` (better-auth 1.7.7, organization plugin) + * and only prefixed with `ba_` so they never collide with Compasso's own `users`/`workspaces`. + * Not part of `src/db/schema.ts` on purpose: the app schema stays untouched by the spike. + */ +import { relations } from "drizzle-orm"; +import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core"; + +export const user = pgTable("ba_user", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("email_verified").default(false).notNull(), + image: text("image"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), +}); + +export const session = pgTable( + "ba_session", + { + id: text("id").primaryKey(), + expiresAt: timestamp("expires_at").notNull(), + token: text("token").notNull().unique(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + activeOrganizationId: text("active_organization_id"), + }, + (table) => [index("session_userId_idx").on(table.userId)], +); + +export const account = pgTable( + "ba_account", + { + id: text("id").primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: timestamp("access_token_expires_at"), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), + scope: text("scope"), + password: text("password"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("account_userId_idx").on(table.userId)], +); + +export const verification = pgTable( + "ba_verification", + { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("verification_identifier_idx").on(table.identifier)], +); + +export const organization = pgTable("ba_organization", { + id: text("id").primaryKey(), + name: text("name").notNull(), + slug: text("slug").notNull().unique(), + logo: text("logo"), + createdAt: timestamp("created_at").notNull(), + metadata: text("metadata"), +}); + +export const member = pgTable( + "ba_member", + { + id: text("id").primaryKey(), + organizationId: text("organization_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + role: text("role").default("member").notNull(), + createdAt: timestamp("created_at").notNull(), + }, + (table) => [ + index("member_organizationId_idx").on(table.organizationId), + index("member_userId_idx").on(table.userId), + ], +); + +export const invitation = pgTable( + "ba_invitation", + { + id: text("id").primaryKey(), + organizationId: text("organization_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + email: text("email").notNull(), + role: text("role"), + status: text("status").default("pending").notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + inviterId: text("inviter_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + }, + (table) => [ + index("invitation_organizationId_idx").on(table.organizationId), + index("invitation_email_idx").on(table.email), + ], +); + +export const userRelations = relations(user, ({ many }) => ({ + sessions: many(session), + accounts: many(account), + members: many(member), + invitations: many(invitation), +})); + +export const sessionRelations = relations(session, ({ one }) => ({ + user: one(user, { + fields: [session.userId], + references: [user.id], + }), +})); + +export const accountRelations = relations(account, ({ one }) => ({ + user: one(user, { + fields: [account.userId], + references: [user.id], + }), +})); + +export const organizationRelations = relations(organization, ({ many }) => ({ + members: many(member), + invitations: many(invitation), +})); + +export const memberRelations = relations(member, ({ one }) => ({ + organization: one(organization, { + fields: [member.organizationId], + references: [organization.id], + }), + user: one(user, { + fields: [member.userId], + references: [user.id], + }), +})); + +export const invitationRelations = relations(invitation, ({ one }) => ({ + organization: one(organization, { + fields: [invitation.organizationId], + references: [organization.id], + }), + user: one(user, { + fields: [invitation.inviterId], + references: [user.id], + }), +})); diff --git a/src/lib/ba/auth-client.ts b/src/lib/ba/auth-client.ts new file mode 100644 index 0000000..c4d9b74 --- /dev/null +++ b/src/lib/ba/auth-client.ts @@ -0,0 +1,6 @@ +"use client"; + +import { emailOTPClient, organizationClient } from "better-auth/client/plugins"; +import { createAuthClient } from "better-auth/react"; + +export const authClient = createAuthClient({ plugins: [emailOTPClient(), organizationClient()] }); diff --git a/src/server/ba/auth.smoke.test.ts b/src/server/ba/auth.smoke.test.ts new file mode 100644 index 0000000..504a965 --- /dev/null +++ b/src/server/ba/auth.smoke.test.ts @@ -0,0 +1,77 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { createTestDb, type TestDb } from "@/test/db"; +import { createAuth } from "./auth"; + +/** + * SPIKE (ADR-033): exercises the real Better Auth (email OTP, organization, invitations) against PGlite with the + * real migrations. It proves the logic and the schema; it does NOT prove the neon-http driver (run the app for that). + */ +describe("better auth smoke", () => { + let db: TestDb; + let auth: ReturnType; + const mails: { to: string; subject: string; text: string }[] = []; + + beforeAll(async () => { + process.env.BETTER_AUTH_SECRET = "test-secret-test-secret-test-secret-123"; + db = await createTestDb(); + auth = createAuth({ + db: db as never, + nextJsCookies: false, + send: async (m) => { + mails.push(m); + }, + }); + }); + + async function signIn(email: string) { + mails.length = 0; + await auth.api.sendVerificationOTP({ body: { email, type: "sign-in" } }); + await new Promise((r) => setTimeout(r, 20)); // the OTP mail is intentionally fire-and-forget + const otp = /(\d{6})/.exec(mails[0].text)![1]; + const res = await auth.api.signInEmailOTP({ body: { email, otp }, returnHeaders: true }); + const cookie = res.headers + .getSetCookie() + .map((c) => c.split(";")[0]) + .join("; "); + return { headers: new Headers({ cookie }), user: res.response.user }; + } + + it("signs in with an e-mail code, creates an organization and invites a second user who joins", async () => { + const owner = await signIn("owner@example.com"); + const org = await auth.api.createOrganization({ headers: owner.headers, body: { name: "Acme", slug: "acme" } }); + await auth.api.setActiveOrganization({ headers: owner.headers, body: { organizationId: org.id } }); + + mails.length = 0; + const invitation = await auth.api.createInvitation({ + headers: owner.headers, + body: { email: "guest@example.com", role: "member", organizationId: org.id }, + }); + expect(mails.at(-1)?.text).toContain(`/spike/accept/${invitation.id}`); + + const guest = await signIn("guest@example.com"); + await auth.api.acceptInvitation({ headers: guest.headers, body: { invitationId: invitation.id } }); + const members = await auth.api.listMembers({ headers: owner.headers, query: { organizationId: org.id } }); + expect(members.members.map((m) => m.role).sort()).toEqual(["member", "owner"]); + + // the session hook: a returning user lands in their first organization + const again = await signIn("owner@example.com"); + const session = await auth.api.getSession({ headers: again.headers }); + expect(session?.session.activeOrganizationId).toBe(org.id); + }); + + it("does not let another e-mail accept someone else's invitation", async () => { + const owner = await signIn("owner2@example.com"); + const org = await auth.api.createOrganization({ headers: owner.headers, body: { name: "Beta", slug: "beta" } }); + const invitation = await auth.api.createInvitation({ + headers: owner.headers, + body: { email: "right@example.com", role: "member", organizationId: org.id }, + }); + const wrong = await signIn("wrong@example.com"); + await expect(auth.api.acceptInvitation({ headers: wrong.headers, body: { invitationId: invitation.id } })).rejects.toThrow(); + }); + + it("rejects a wrong code", async () => { + await auth.api.sendVerificationOTP({ body: { email: "x@example.com", type: "sign-in" } }); + await expect(auth.api.signInEmailOTP({ body: { email: "x@example.com", otp: "000000" } })).rejects.toThrow(); + }); +}); diff --git a/src/server/ba/auth.ts b/src/server/ba/auth.ts new file mode 100644 index 0000000..136647c --- /dev/null +++ b/src/server/ba/auth.ts @@ -0,0 +1,86 @@ +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { nextCookies } from "better-auth/next-js"; +import { emailOTP, organization } from "better-auth/plugins"; +import { asc, eq } from "drizzle-orm"; +import { getDb } from "@/db"; +import * as authSchema from "@/db/auth-schema"; +import { sendMail } from "./mail"; + +type AuthDb = Parameters[0]; +type Mailer = (message: { to: string; subject: string; text: string }) => Promise; + +/** Factory so tests can inject an in-memory database and capture e-mails; the app uses `getAuth()`. */ +export function createAuth({ + db, + send = sendMail, + nextJsCookies = true, +}: { + db: AuthDb & ReturnType; + send?: Mailer; + nextJsCookies?: boolean; +}) { + const baseURL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000"; + const google = + process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET + ? { google: { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET } } + : {}; + const github = + process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET + ? { github: { clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET } } + : {}; + + return betterAuth({ + baseURL, + secret: process.env.BETTER_AUTH_SECRET, + // neon-http (ADR-003): for provider "pg" the adapter only opens transactions when `transaction: true`, which we leave off. + database: drizzleAdapter(db, { provider: "pg", schema: authSchema }), + socialProviders: { ...google, ...github }, + databaseHooks: { + session: { + create: { + // Sign in lands directly in the user's first organization (Clerk did this with the "active org"). + before: async (session) => { + const [first] = await db + .select({ organizationId: authSchema.member.organizationId }) + .from(authSchema.member) + .where(eq(authSchema.member.userId, session.userId)) + .orderBy(asc(authSchema.member.createdAt)) + .limit(1); + return { data: { ...session, activeOrganizationId: first?.organizationId ?? null } }; + }, + }, + }, + }, + plugins: [ + emailOTP({ + otpLength: 6, + expiresIn: 300, + allowedAttempts: 3, + async sendVerificationOTP({ email, otp }) { + // Not awaited on purpose (timing attacks); errors are only logged. On Vercel use `after()` if delivery gets cut. + void send({ to: email, subject: "Seu código de acesso ao Compasso", text: `Seu código: ${otp}\nVálido por 5 minutos.` }).catch( + (e) => console.error("[spike mail] failed", e), + ); + }, + }), + organization({ + async sendInvitationEmail({ id, email, organization: org, inviter }) { + await send({ + to: email, + subject: `${inviter.user.name} convidou você para ${org.name} no Compasso`, + text: `Aceite o convite: ${baseURL}/spike/accept/${id}\n(expira em 48 horas)`, + }); + }, + }), + ...(nextJsCookies ? [nextCookies()] : []), // must be the last plugin + ], + }); +} + +let instance: ReturnType | undefined; + +/** Lazy singleton, like getDb(): `next build` must not need secrets. */ +export function getAuth() { + return (instance ??= createAuth({ db: getDb() })); +} diff --git a/src/server/ba/context.test.ts b/src/server/ba/context.test.ts new file mode 100644 index 0000000..3884422 --- /dev/null +++ b/src/server/ba/context.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { roleFromBetterAuth } from "./context"; + +describe("roleFromBetterAuth", () => { + it("treats owner and admin as workspace admin", () => { + expect(roleFromBetterAuth("owner")).toBe("admin"); + expect(roleFromBetterAuth("admin")).toBe("admin"); + expect(roleFromBetterAuth("member,admin")).toBe("admin"); + }); + it("treats member, unknown and empty roles as member", () => { + expect(roleFromBetterAuth("member")).toBe("member"); + expect(roleFromBetterAuth("viewer")).toBe("member"); + expect(roleFromBetterAuth(null)).toBe("member"); + expect(roleFromBetterAuth(undefined)).toBe("member"); + }); +}); diff --git a/src/server/ba/context.ts b/src/server/ba/context.ts new file mode 100644 index 0000000..d58e58e --- /dev/null +++ b/src/server/ba/context.ts @@ -0,0 +1,54 @@ +import { and, eq } from "drizzle-orm"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { getDb } from "@/db"; +import * as authSchema from "@/db/auth-schema"; +import { slugify } from "@/lib/slug"; +import { upsertMembership, upsertUser, upsertWorkspace } from "@/server/clerk-sync"; +import type { WorkspaceContext } from "@/server/workspace-context"; +import type { WorkspaceRole } from "@/lib/roles"; +import { getAuth } from "./auth"; + +/** + * SPIKE (ADR-033): same contract as `requireWorkspaceContext()` (Clerk), fed by Better Auth. + * To avoid migrating public tables during the spike, Better Auth ids are mirrored into the existing + * `users.clerk_id` / `workspaces.clerk_org_id` columns with a "ba:" prefix. A real migration would rename them. + */ +export function roleFromBetterAuth(role: string | null | undefined): WorkspaceRole { + // The plugin stores comma-separated roles; owner and admin both administer the workspace. + const roles = (role ?? "").split(",").map((r) => r.trim()); + return roles.includes("owner") || roles.includes("admin") ? "admin" : "member"; +} + +export async function requireBaWorkspaceContext(): Promise { + const session = await getAuth().api.getSession({ headers: await headers() }); + if (!session) redirect("/spike/sign-in"); + const orgId = session.session.activeOrganizationId; + if (!orgId) redirect("/spike"); + + const db = getDb(); + const [row] = await db + .select({ role: authSchema.member.role, orgName: authSchema.organization.name, orgSlug: authSchema.organization.slug }) + .from(authSchema.member) + .innerJoin(authSchema.organization, eq(authSchema.organization.id, authSchema.member.organizationId)) + .where(and(eq(authSchema.member.userId, session.user.id), eq(authSchema.member.organizationId, orgId))) + .limit(1); + if (!row) redirect("/spike"); // stale active organization: the user is no longer a member + + // Same idempotent upserts as the Clerk flow (neon-http, no transactions). + const user = await upsertUser({ + clerkId: `ba:${session.user.id}`, + email: session.user.email, + name: session.user.name || null, + avatarUrl: session.user.image ?? null, + }); + if (!user) throw new Error("User was deleted"); + const workspace = await upsertWorkspace( + { clerkOrgId: `ba:${orgId}`, name: row.orgName, slug: `${slugify(row.orgSlug, 40)}-${orgId.slice(-6).toLowerCase()}` }, + { unarchive: true }, + ); + const role = roleFromBetterAuth(row.role); + await upsertMembership(workspace.id, user.id, role); + + return { userId: user.id, workspaceId: workspace.id, role, timezone: user.timezone, userName: user.name, workspaceName: workspace.name }; +} diff --git a/src/server/ba/mail.ts b/src/server/ba/mail.ts new file mode 100644 index 0000000..7068bf7 --- /dev/null +++ b/src/server/ba/mail.ts @@ -0,0 +1,17 @@ +/** + * SPIKE: minimal transactional mail. With RESEND_API_KEY it posts to the Resend REST API (no SDK dependency); + * without it the message is logged to the server console, which is enough to test the flows locally. + */ +export async function sendMail(message: { to: string; subject: string; text: string }) { + const apiKey = process.env.RESEND_API_KEY; + if (!apiKey) { + console.info(`[spike mail] to=${message.to} subject="${message.subject}"\n${message.text}`); + return; + } + const res = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, + body: JSON.stringify({ from: process.env.MAIL_FROM ?? "Compasso ", ...message }), + }); + if (!res.ok) throw new Error(`Resend responded ${res.status}`); +}