diff --git a/.cursor/README.md b/.cursor/README.md index 3b099f9..3200cb4 100644 --- a/.cursor/README.md +++ b/.cursor/README.md @@ -8,7 +8,7 @@ This project uses the new Cursor project rules structure with **MDC format** for .cursor/rules/ # Project-wide rules and overview project.md # General patterns, security, architecture -server/.cursor/rules/ # Backend-specific rules +server/.cursor/rules/ # Backend-specific rules api.md # API patterns, database, server-side auth components/.cursor/rules/ # Frontend-specific rules @@ -18,18 +18,21 @@ components/.cursor/rules/ # Frontend-specific rules ## Rule Categories ### Project-wide (`.cursor/rules/`) + - Project overview and architecture with Mermaid diagrams - Common patterns across the entire codebase - Security guidelines and migration notes - Development workflow and best practices ### Server (`server/.cursor/rules/`) + - API route patterns and error handling - Database schema and Drizzle ORM usage - Better-Auth server-side integration - Environment configuration and security ### Components (`components/.cursor/rules/`) + - Vue 3 Options API patterns with examples - shadcn-vue component usage and styling - Client-side authentication and navigation @@ -58,7 +61,7 @@ The rules files use **MDC (Markdown Components)** format with: When working in different directories, Cursor automatically applies the most relevant rules: - **Root directory**: General project patterns and architecture -- **`/server/` directory**: Backend API and database patterns +- **`/server/` directory**: Backend API and database patterns - **`/components/` directory**: Vue component and UI patterns -This ensures you get contextual AI assistance based on what part of the application you're working on. \ No newline at end of file +This ensures you get contextual AI assistance based on what part of the application you're working on. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2fbdd23 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,143 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + # ── Lint (ESLint + Prettier) ────────────────────────────────────── + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v5 + with: + node-version: '20' + cache: yarn + + - run: yarn install --frozen-lockfile + + - run: yarn lint + + - run: yarn format:check + + # ── Type check (vue-tsc) ────────────────────────────────────────── + typecheck: + name: Type Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v5 + with: + node-version: '20' + cache: yarn + + - run: yarn install --frozen-lockfile + # postinstall runs `nuxt prepare`, which generates .nuxt/tsconfig.json + + - run: yarn typecheck + + # ── Unit tests (Vitest) ─────────────────────────────────────────── + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v5 + with: + node-version: '20' + cache: yarn + + - run: yarn install --frozen-lockfile + + - run: yarn test + + # ── Production build ────────────────────────────────────────────── + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v5 + with: + node-version: '20' + cache: yarn + + - run: yarn install --frozen-lockfile + + # Run nuxt build directly (not `yarn build`) to skip the + # postbuild lifecycle hook, which runs db migrations and + # requires a live PostgreSQL connection. + - run: npx nuxt build + + e2e: + name: E2E (Playwright) + if: github.event_name == 'push' + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: veerify + POSTGRES_PASSWORD: veerifypassword + POSTGRES_DB: veerifydb + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U veerify -d veerifydb" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + PGHOST: localhost + PGPORT: 5432 + PGUSER: veerify + PGPASSWORD: veerifypassword + PGDATABASE: veerifydb + BETTER_AUTH_URL: http://localhost:4173 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v5 + with: + node-version: '20' + cache: yarn + + - run: yarn install --frozen-lockfile + + - name: Install Playwright browser + run: npx playwright install --with-deps chromium + + - name: Run migrations + run: yarn db:migrate + + - name: Seed test data + run: yarn db:seed:clean && yarn db:seed + + - name: Run Playwright (guarded) + run: yarn test:e2e:if-available + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report + if-no-files-found: ignore + retention-days: 7 + + - name: Upload Playwright results + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-results + path: test-results + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/neon.yml b/.github/workflows/neon.yml new file mode 100644 index 0000000..b70ccaf --- /dev/null +++ b/.github/workflows/neon.yml @@ -0,0 +1,149 @@ +name: Create/Delete Branch for Pull Request + +on: + pull_request: + types: + - opened + - reopened + - synchronize + - closed + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + +jobs: + setup: + name: Setup + outputs: + branch: ${{ steps.branch_name.outputs.current_branch }} + runs-on: ubuntu-latest + steps: + - name: Get branch name + id: branch_name + uses: tj-actions/branch-names@v8 + + create_neon_branch: + name: Create Neon Branch + outputs: + db_url: ${{ steps.create_neon_branch.outputs.db_url }} + db_url_with_pooler: ${{ steps.create_neon_branch.outputs.db_url_with_pooler }} + needs: setup + if: | + github.event_name == 'pull_request' && ( + github.event.action == 'synchronize' + || github.event.action == 'opened' + || github.event.action == 'reopened') + runs-on: ubuntu-latest + steps: + - name: Get branch expiration date as an env variable (2 weeks from now) + id: get_expiration_date + run: echo "EXPIRES_AT=$(date -u --date '+14 days' +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_ENV" + - name: Create Neon Branch + id: create_neon_branch + uses: neondatabase/create-branch-action@v6 + with: + project_id: ${{ vars.NEON_PROJECT_ID }} + branch_name: preview/pr-${{ github.event.number }}-${{ needs.setup.outputs.branch }} + api_key: ${{ secrets.NEON_API_KEY }} + expires_at: ${{ env.EXPIRES_AT }} + + playwright_e2e: + name: PR E2E (Playwright on Neon) + needs: create_neon_branch + if: | + github.event_name == 'pull_request' && ( + github.event.action == 'synchronize' + || github.event.action == 'opened' + || github.event.action == 'reopened') + runs-on: ubuntu-latest + env: + DATABASE_URL: ${{ needs.create_neon_branch.outputs.db_url_with_pooler }} + BETTER_AUTH_URL: http://localhost:4173 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v5 + with: + node-version: '20' + cache: yarn + + - run: yarn install --frozen-lockfile + + - name: Install Playwright browser + run: npx playwright install --with-deps chromium + + - name: Run migrations + run: yarn db:migrate + + - name: Seed test data + run: yarn db:seed:clean && yarn db:seed + + - name: Run Playwright + run: yarn test:e2e:if-available + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: neon-playwright-report-pr-${{ github.event.number }} + path: playwright-report + if-no-files-found: ignore + retention-days: 7 + + - name: Upload Playwright results + if: always() + uses: actions/upload-artifact@v4 + with: + name: neon-playwright-results-pr-${{ github.event.number }} + path: test-results + if-no-files-found: ignore + retention-days: 7 + +# The step above creates a new Neon branch. +# You may want to do something with the new branch, such as run migrations, run tests +# on it, or send the connection details to a hosting platform environment. +# The branch DATABASE_URL is available to you via: +# "${{ steps.create_neon_branch.outputs.db_url_with_pooler }}". +# It's important you don't log the DATABASE_URL as output as it contains a username and +# password for your database. +# For example, you can uncomment the lines below to run a database migration command: +# - name: Run Migrations +# run: npm run db:migrate +# env: +# # to use pooled connection +# DATABASE_URL: "${{ steps.create_neon_branch.outputs.db_url_with_pooler }}" +# # OR to use unpooled connection +# # DATABASE_URL: "${{ steps.create_neon_branch.outputs.db_url }}" + +# Following the step above, which runs database migrations, you may want to check +# for schema changes in your database. We recommend using the following action to +# post a comment to your pull request with the schema diff. For this action to work, +# you also need to give permissions to the workflow job to be able to post comments +# and read your repository contents. Add the following permissions to the workflow job: +# +# permissions: +# contents: read +# pull-requests: write +# +# You can also check out https://github.com/neondatabase/schema-diff-action for more +# information on how to use the schema diff action. +# You can uncomment the lines below to enable the schema diff action. +# - name: Post Schema Diff Comment to PR +# uses: neondatabase/schema-diff-action@v1 +# with: +# project_id: ${{ vars.NEON_PROJECT_ID }} +# compare_branch: preview/pr-${{ github.event.number }}-${{ needs.setup.outputs.branch }} +# api_key: ${{ secrets.NEON_API_KEY }} + + delete_neon_branch: + name: Delete Neon Branch + needs: setup + if: github.event_name == 'pull_request' && github.event.action == 'closed' + runs-on: ubuntu-latest + steps: + - name: Delete Neon Branch + uses: neondatabase/delete-branch-action@v3 + with: + project_id: ${{ vars.NEON_PROJECT_ID }} + branch: preview/pr-${{ github.event.number }}-${{ needs.setup.outputs.branch }} + api_key: ${{ secrets.NEON_API_KEY }} diff --git a/.gitignore b/.gitignore index 4a7f73a..4d8892c 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ logs .DS_Store .fleet .idea +playwright-report +test-results # Local env files .env diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..79bee78 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +node_modules +.nuxt +.output +dist +.cache +server/database/migrations diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..3b5eb66 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "semi": false, + "singleQuote": true, + "trailingComma": "es5", + "printWidth": 120, + "tabWidth": 2, + "bracketSpacing": true +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e15f34e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,15 @@ +# Agent Memory + +## Post-change validation checklist +- Run `yarn typecheck`. +- Run `yarn test`. +- Run `yarn lint`. +- Run `yarn test:e2e:if-available`. + +## Playwright execution rule +- `yarn test:e2e:if-available` must run Playwright only when the environment is available (cloud/CI or explicitly forced with `PLAYWRIGHT_FORCE=1`) and a database connection is configured. +- If the guarded Playwright command skips, include the skip reason in the update/final response. + +## UI change policy +- For any task that adds or changes user-facing UI behavior, create or update Playwright coverage for the affected workflow before considering the task complete. +- Run Playwright repeatedly until the updated UI workflow tests pass (not just a single run). diff --git a/CLAUDE.md b/CLAUDE.md index 2ea8f72..9893143 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,19 +12,19 @@ Veerify is a feedback management and verification platform built with **Nuxt 3** ## Technology Stack -| Layer | Technology | -|---|---| -| Framework | Nuxt 3 (Vue 3) | -| Language | TypeScript | -| UI Components | shadcn-vue (New York style) | -| Styling | Tailwind CSS v4 (`@tailwindcss/vite`) | -| Icons | Nuxt Icon — Lucide icon set | -| Authentication | Better-Auth v1.2+ | -| ORM | Drizzle ORM | -| Database | PostgreSQL 17.5 | -| Email | Nodemailer via `nuxt-nodemailer` | -| Dark Mode | `@nuxtjs/color-mode` (system preference, dark fallback) | -| Package Manager | Yarn | +| Layer | Technology | +| --------------- | ------------------------------------------------------- | +| Framework | Nuxt 3 (Vue 3) | +| Language | TypeScript | +| UI Components | shadcn-vue (New York style) | +| Styling | Tailwind CSS v4 (`@tailwindcss/vite`) | +| Icons | Nuxt Icon — Lucide icon set | +| Authentication | Better-Auth v1.2+ | +| ORM | Drizzle ORM | +| Database | PostgreSQL 17.5 | +| Email | Nodemailer via `nuxt-nodemailer` | +| Dark Mode | `@nuxtjs/color-mode` (system preference, dark fallback) | +| Package Manager | Yarn | --- @@ -85,6 +85,7 @@ Veerify is a feedback management and verification platform built with **Nuxt 3** ## Development Setup ### Prerequisites + - Node.js 18+ - Yarn - Docker (for local PostgreSQL + Mailpit) @@ -131,15 +132,15 @@ During development, emails are captured by Mailpit — they are never sent to re ## NPM Scripts -| Script | Purpose | -|---|---| -| `yarn dev` | Start Nuxt dev server | -| `yarn build` | Production build | -| `yarn preview` | Preview production build locally | -| `yarn db:generate` | Generate a new Drizzle migration from schema changes | -| `yarn db:migrate` | Run pending migrations against the database | -| `yarn db:push` | Push schema directly (no migration file — dev use only) | -| `yarn db:studio` | Open Drizzle Studio UI | +| Script | Purpose | +| ------------------ | ------------------------------------------------------- | +| `yarn dev` | Start Nuxt dev server | +| `yarn build` | Production build | +| `yarn preview` | Preview production build locally | +| `yarn db:generate` | Generate a new Drizzle migration from schema changes | +| `yarn db:migrate` | Run pending migrations against the database | +| `yarn db:push` | Push schema directly (no migration file — dev use only) | +| `yarn db:studio` | Open Drizzle Studio UI | --- @@ -199,11 +200,11 @@ import { authClient, signIn, signUp, signOut, useSession } from '~/lib/auth-clie Runs on the **client only** (`process.server` check skips SSR/build). Categorises routes: -| Category | Routes | Behaviour | -|---|---|---| -| Protected | `/dashboard`, `/settings`, `/team`, `/reports`, `/feedback`, `/help` | Redirect to `/login` if no session | -| Auth | `/login`, `/signup`, `/auth` | Redirect to `/dashboard` if session exists | -| Public | everything else | No redirect | +| Category | Routes | Behaviour | +| --------- | -------------------------------------------------------------------- | ------------------------------------------ | +| Protected | `/dashboard`, `/settings`, `/team`, `/reports`, `/feedback`, `/help` | Redirect to `/login` if no session | +| Auth | `/login`, `/signup`, `/auth` | Redirect to `/dashboard` if session exists | +| Public | everything else | No redirect | --- @@ -217,16 +218,16 @@ Uses `drizzle-orm/node-postgres` with a raw `pg` `Client`. Connection parameters Eight tables, all defined with `pgTable` from `drizzle-orm/pg-core`: -| Table | Key Columns | Notes | -|---|---|---| -| `user` | id, name, email (unique), emailVerified, twoFactorEnabled | Core identity | -| `session` | id, token (unique), userId (FK→user), expiresAt, activeOrganizationId | Auth sessions | -| `account` | id, accountId, providerId, userId (FK→user), password | Stores credential per provider | -| `verification` | id, identifier, value, expiresAt | Email verification & password reset tokens | -| `organization` | id, name, slug (unique), logo | Multi-tenant orgs | -| `member` | id, organizationId (FK→organization), userId (FK→user), role | Org membership | -| `invitation` | id, organizationId, email, role, status, inviterId, expiresAt | Pending invites | -| `twoFactor` | id, userId (FK→user), secret, backupCodes | TOTP 2FA | +| Table | Key Columns | Notes | +| -------------- | --------------------------------------------------------------------- | ------------------------------------------ | +| `user` | id, name, email (unique), emailVerified, twoFactorEnabled | Core identity | +| `session` | id, token (unique), userId (FK→user), expiresAt, activeOrganizationId | Auth sessions | +| `account` | id, accountId, providerId, userId (FK→user), password | Stores credential per provider | +| `verification` | id, identifier, value, expiresAt | Email verification & password reset tokens | +| `organization` | id, name, slug (unique), logo | Multi-tenant orgs | +| `member` | id, organizationId (FK→organization), userId (FK→user), role | Org membership | +| `invitation` | id, organizationId, email, role, status, inviterId, expiresAt | Pending invites | +| `twoFactor` | id, userId (FK→user), secret, backupCodes | TOTP 2FA | ### Migration Workflow @@ -280,11 +281,19 @@ The project **migrated away from Composition API**. All components must use the export default { name: 'ComponentName', data() { - return { /* reactive state */ } + return { + /* reactive state */ + } + }, + computed: { + /* derived state */ + }, + methods: { + /* actions */ + }, + async mounted() { + /* lifecycle */ }, - computed: { /* derived state */ }, - methods: { /* actions */ }, - async mounted() { /* lifecycle */ } } ``` @@ -294,6 +303,7 @@ export default { ### Auto-Imports Nuxt auto-imports components from `components/` and `components/ui/`. You do **not** need to manually import: + - Any shadcn-vue UI component (`Button`, `Input`, `Card`, `Skeleton`, `Avatar`, etc.) - Any component inside `components/` (e.g. `AppSidebar`, `SettingsProfile`) - Nuxt built-ins (`NuxtLink`, `navigateTo`) @@ -319,8 +329,12 @@ Always use the Nuxt Icon component with Lucide: Use `import.meta.client` (not the deprecated `process.client`): ```js -if (import.meta.client) { /* browser-only code */ } -if (import.meta.server) { /* server-only code */ } +if (import.meta.client) { + /* browser-only code */ +} +if (import.meta.server) { + /* server-only code */ +} ``` ### Tailwind & Theming @@ -337,11 +351,11 @@ if (import.meta.server) { /* server-only code */ } Nuxt maps files under `server/api/` directly to routes. Method-specific files use suffixes: -| File | Route | Method | -|---|---|---| -| `server/api/auth/[...all].ts` | `/api/auth/*` | All (Better-Auth catch-all) | -| `server/api/mail/send-mail.post.ts` | `/api/mail/send-mail` | POST | -| `server/api/user/profile.put.ts` | `/api/user/profile` | PUT | +| File | Route | Method | +| ----------------------------------- | --------------------- | --------------------------- | +| `server/api/auth/[...all].ts` | `/api/auth/*` | All (Better-Auth catch-all) | +| `server/api/mail/send-mail.post.ts` | `/api/mail/send-mail` | POST | +| `server/api/user/profile.put.ts` | `/api/user/profile` | PUT | ### Protected Route Template @@ -397,11 +411,11 @@ throw createError({ statusCode: 500, statusMessage: 'Internal server error' }) ### Route Categories -| Route | Layout | Auth State | -|---|---|---| -| `/` | — | Redirects based on session | -| `/login`, `/signup`, `/forgot-password` | `clean` | Public (redirects away if authed) | -| `/dashboard`, `/feedback`, `/reports`, `/settings`, `/help` | `dashboard` | Protected | +| Route | Layout | Auth State | +| ----------------------------------------------------------- | ----------- | --------------------------------- | +| `/` | — | Redirects based on session | +| `/login`, `/signup`, `/forgot-password` | `clean` | Public (redirects away if authed) | +| `/dashboard`, `/feedback`, `/reports`, `/settings`, `/help` | `dashboard` | Protected | --- @@ -409,10 +423,10 @@ throw createError({ statusCode: 500, statusMessage: 'Internal server error' }) The project ships context-aware Cursor rules in MDC format: -| File | Scope | -|---|---| -| `.cursor/rules/project.mdc` | Project-wide patterns, security, architecture | -| `server/.cursor/rules/api.mdc` | API routes, database, server auth | +| File | Scope | +| ---------------------------------- | --------------------------------------------- | +| `.cursor/rules/project.mdc` | Project-wide patterns, security, architecture | +| `server/.cursor/rules/api.mdc` | API routes, database, server auth | | `components/.cursor/rules/vue.mdc` | Vue components, UI patterns, client-side auth | These mirror the conventions in this file. Keep them in sync when making architectural changes. diff --git a/README.md b/README.md index 33f8cb3..522a8f7 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Veerify +[![CI](https://github.com/Frogbyte-io/veerify/actions/workflows/ci.yml/badge.svg)](https://github.com/Frogbyte-io/veerify/actions/workflows/ci.yml) + A modern feedback management platform built with Nuxt 3, TypeScript, and shadcn-vue. Veerify helps you collect, organize, and prioritize user feedback to build better products - similar to Sleekplan, Canny, and Featurebase. ## 🌟 Features @@ -27,7 +29,7 @@ A modern feedback management platform built with Nuxt 3, TypeScript, and shadcn- ### Prerequisites -- Node.js 18+ +- Node.js 18+ - Yarn package manager ### Installation @@ -139,10 +141,10 @@ No GitHub Actions or per-PR configuration required — the Neon Vercel integrati **Test credentials on preview deployments:** -| Field | Value | -|---|---| -| Email | `test@preview.local` | -| Password | `password123` | +| Field | Value | +| -------- | -------------------- | +| Email | `test@preview.local` | +| Password | `password123` | ## 📁 Project Structure diff --git a/app.vue b/app.vue index dc2e63f..7009ff2 100644 --- a/app.vue +++ b/app.vue @@ -7,12 +7,12 @@ diff --git a/assets/css/main.css b/assets/css/main.css index 0a61557..30841b6 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -1,5 +1,5 @@ -@import "tailwindcss"; -@import "tw-animate-css"; +@import 'tailwindcss'; +@import 'tw-animate-css'; @custom-variant dark (&:is(.dark *)); @@ -120,4 +120,4 @@ body { @apply bg-background text-foreground; } -} \ No newline at end of file +} diff --git a/auth-schema.ts b/auth-schema.ts index 7c0a48b..8c2c608 100644 --- a/auth-schema.ts +++ b/auth-schema.ts @@ -1,75 +1,151 @@ -import { pgTable, text, timestamp, boolean, integer } from "drizzle-orm/pg-core"; +import { pgTable, text, timestamp, boolean, index, uniqueIndex } from 'drizzle-orm/pg-core' -export const user = pgTable("user", { - id: text('id').primaryKey(), - name: text('name').notNull(), - email: text('email').notNull().unique(), - emailVerified: boolean('email_verified').$defaultFn(() => false).notNull(), - image: text('image'), - createdAt: timestamp('created_at').$defaultFn(() => /* @__PURE__ */ new Date()).notNull(), - updatedAt: timestamp('updated_at').$defaultFn(() => /* @__PURE__ */ new Date()).notNull() - }); +export const user = pgTable('user', { + id: text('id').primaryKey(), + name: text('name').notNull(), + email: text('email').notNull().unique(), + emailVerified: boolean('email_verified') + .$defaultFn(() => false) + .notNull(), + image: text('image'), + createdAt: timestamp('created_at') + .$defaultFn(() => /* @__PURE__ */ new Date()) + .notNull(), + updatedAt: timestamp('updated_at') + .$defaultFn(() => /* @__PURE__ */ new Date()) + .notNull(), +}) -export const session = pgTable("session", { - id: text('id').primaryKey(), - expiresAt: timestamp('expires_at').notNull(), - token: text('token').notNull().unique(), - createdAt: timestamp('created_at').notNull(), - updatedAt: timestamp('updated_at').notNull(), - ipAddress: text('ip_address'), - userAgent: text('user_agent'), - userId: text('user_id').notNull().references(()=> user.id, { onDelete: 'cascade' }), - activeOrganizationId: text('active_organization_id') - }); +export const session = pgTable('session', { + id: text('id').primaryKey(), + expiresAt: timestamp('expires_at').notNull(), + token: text('token').notNull().unique(), + createdAt: timestamp('created_at').notNull(), + updatedAt: timestamp('updated_at').notNull(), + ipAddress: text('ip_address'), + userAgent: text('user_agent'), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + activeOrganizationId: text('active_organization_id'), + activeTeamId: text('active_team_id'), +}) -export const account = pgTable("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').notNull(), - updatedAt: timestamp('updated_at').notNull() - }); +export const account = pgTable('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').notNull(), + updatedAt: timestamp('updated_at').notNull(), +}) -export const verification = pgTable("verification", { - id: text('id').primaryKey(), - identifier: text('identifier').notNull(), - value: text('value').notNull(), - expiresAt: timestamp('expires_at').notNull(), - createdAt: timestamp('created_at').$defaultFn(() => /* @__PURE__ */ new Date()), - updatedAt: timestamp('updated_at').$defaultFn(() => /* @__PURE__ */ new Date()) - }); +export const verification = pgTable('verification', { + id: text('id').primaryKey(), + identifier: text('identifier').notNull(), + value: text('value').notNull(), + expiresAt: timestamp('expires_at').notNull(), + createdAt: timestamp('created_at').$defaultFn(() => /* @__PURE__ */ new Date()), + updatedAt: timestamp('updated_at').$defaultFn(() => /* @__PURE__ */ new Date()), +}) -export const organization = pgTable("organization", { - id: text('id').primaryKey(), - name: text('name').notNull(), - slug: text('slug').unique(), - logo: text('logo'), - createdAt: timestamp('created_at').notNull(), - metadata: text('metadata') - }); +export const organization = pgTable('organization', { + id: text('id').primaryKey(), + name: text('name').notNull(), + slug: text('slug').unique(), + logo: text('logo'), + createdAt: timestamp('created_at') + .$defaultFn(() => /* @__PURE__ */ new Date()) + .notNull(), + updatedAt: timestamp('updated_at') + .$defaultFn(() => /* @__PURE__ */ new Date()) + .notNull(), + metadata: text('metadata'), +}) -export const member = pgTable("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() - }); +export const member = pgTable('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') + .$defaultFn(() => /* @__PURE__ */ new Date()) + .notNull(), + updatedAt: timestamp('updated_at') + .$defaultFn(() => /* @__PURE__ */ new Date()) + .notNull(), +}) -export const invitation = pgTable("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(), - inviterId: text('inviter_id').notNull().references(()=> user.id, { onDelete: 'cascade' }) - }); +export const team = pgTable( + 'team', + { + id: text('id').primaryKey(), + name: text('name').notNull(), + organizationId: text('organization_id') + .notNull() + .references(() => organization.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at') + .$defaultFn(() => /* @__PURE__ */ new Date()) + .notNull(), + updatedAt: timestamp('updated_at') + .$defaultFn(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => ({ + orgIdx: index('team_org_idx').on(table.organizationId), + }) +) + +export const teamMember = pgTable( + 'team_member', + { + id: text('id').primaryKey(), + teamId: text('team_id') + .notNull() + .references(() => team.id, { onDelete: 'cascade' }), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at') + .$defaultFn(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => ({ + teamUserUnique: uniqueIndex('team_member_team_user_unique').on(table.teamId, table.userId), + userIdx: index('team_member_user_idx').on(table.userId), + }) +) + +export const invitation = pgTable('invitation', { + id: text('id').primaryKey(), + organizationId: text('organization_id') + .notNull() + .references(() => organization.id, { onDelete: 'cascade' }), + email: text('email').notNull(), + role: text('role').default('member').notNull(), + teamId: text('team_id').references(() => team.id, { onDelete: 'set null' }), + status: text('status').default('pending').notNull(), + expiresAt: timestamp('expires_at').notNull(), + inviterId: text('inviter_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at') + .$defaultFn(() => /* @__PURE__ */ new Date()) + .notNull(), + updatedAt: timestamp('updated_at') + .$defaultFn(() => /* @__PURE__ */ new Date()) + .notNull(), +}) diff --git a/components.json b/components.json index df9560a..972fb2c 100644 --- a/components.json +++ b/components.json @@ -17,4 +17,4 @@ "lib": "@/lib" }, "iconLibrary": "lucide" -} \ No newline at end of file +} diff --git a/components/settings/SettingsAppearance.vue b/components/settings/SettingsAppearance.vue index 3508fac..84f9eea 100644 --- a/components/settings/SettingsAppearance.vue +++ b/components/settings/SettingsAppearance.vue @@ -9,73 +9,78 @@

Theme

- - -
- +
@@ -91,9 +96,7 @@ Always use dark mode regardless of system preference. - - Loading theme preferences... - + Loading theme preferences...

@@ -110,13 +113,13 @@ export default { data() { return { isClient: false, - colorMode: null + colorMode: null, } }, computed: { currentTheme() { return this.colorMode?.preference || 'system' - } + }, }, mounted() { this.isClient = true @@ -127,7 +130,7 @@ export default { if (this.colorMode) { this.colorMode.preference = mode } - } - } + }, + }, } - \ No newline at end of file + diff --git a/components/settings/SettingsBilling.vue b/components/settings/SettingsBilling.vue index 1932e4f..9756393 100644 --- a/components/settings/SettingsBilling.vue +++ b/components/settings/SettingsBilling.vue @@ -1,5 +1,5 @@ \ No newline at end of file + diff --git a/components/settings/SettingsProfile.vue b/components/settings/SettingsProfile.vue index cd604a7..43a3d08 100644 --- a/components/settings/SettingsProfile.vue +++ b/components/settings/SettingsProfile.vue @@ -11,22 +11,18 @@
- +

Unable to load profile information

- +
- + -
+
- + @@ -44,11 +40,9 @@ {{ fieldErrors.name }}

- +
- + @@ -67,27 +61,17 @@

- +
- -
@@ -101,79 +85,77 @@ import { toast } from 'vue-sonner' export default { name: 'SettingsProfile', - + data() { return { session: null, isPending: true, formData: { name: '', - email: '' + email: '', }, originalData: { name: '', - email: '' + email: '', }, fieldErrors: { name: '', - email: '' + email: '', }, - isSaving: false + isSaving: false, } }, - async mounted() { - await this.loadProfile() - }, - computed: { user() { return this.session?.user || null }, - + isLoading() { return this.isPending }, - + hasChanges() { - return this.formData.name !== this.originalData.name || - this.formData.email !== this.originalData.email - } + return this.formData.name !== this.originalData.name || this.formData.email !== this.originalData.email + }, + }, + + async mounted() { + await this.loadProfile() }, methods: { async loadProfile() { try { this.isPending = true - + const { data: session, isPending } = await authClient.useSession(useFetch) this.session = session.value this.isPending = isPending.value - + if (this.session?.user) { this.formData = { name: this.session.user.name || '', - email: this.session.user.email || '' + email: this.session.user.email || '', } this.originalData = { ...this.formData } } - + // Watch for changes watch(session, (newSession) => { this.session = newSession if (newSession?.user) { this.formData = { name: newSession.user.name || '', - email: newSession.user.email || '' + email: newSession.user.email || '', } this.originalData = { ...this.formData } } }) - + watch(isPending, (newPending) => { this.isPending = newPending }) - } catch (error) { console.error('Error loading profile:', error) toast.error('Failed to load profile information') @@ -234,23 +216,22 @@ export default { method: 'PUT', body: { name: this.formData.name.trim(), - email: this.formData.email.trim() - } + email: this.formData.email.trim(), + }, }) if (response.success) { toast.success('Profile updated successfully') this.originalData = { ...this.formData } } - } catch (error) { console.error('Error updating profile:', error) - + let errorMessage = 'Failed to update profile. Please try again.' - + if (error.data?.statusMessage) { errorMessage = error.data.statusMessage - + // Handle specific field errors from server if (errorMessage.includes('email')) { this.fieldErrors.email = errorMessage @@ -260,7 +241,7 @@ export default { } else if (error.statusMessage) { errorMessage = error.statusMessage } - + toast.error(errorMessage) } finally { this.isSaving = false @@ -270,7 +251,7 @@ export default { resetForm() { this.formData = { ...this.originalData } this.clearAllErrors() - } - } + }, + }, } - \ No newline at end of file + diff --git a/components/settings/SettingsSecurity.vue b/components/settings/SettingsSecurity.vue index 8025322..ecba1d9 100644 --- a/components/settings/SettingsSecurity.vue +++ b/components/settings/SettingsSecurity.vue @@ -9,13 +9,13 @@

Change Password

-
+
New Password Confirm New Password
- +
@@ -59,21 +59,21 @@
- + - + - +

Two-Factor Authentication

- +
-
@@ -82,9 +82,10 @@ Two-Factor Authentication {{ user?.twoFactorEnabled ? 'Enabled' : 'Disabled' }}

- {{ user?.twoFactorEnabled - ? 'Your account is secured with 2FA' - : 'Add an extra layer of security to your account' + {{ + user?.twoFactorEnabled + ? 'Your account is secured with 2FA' + : 'Add an extra layer of security to your account' }}

@@ -102,26 +103,27 @@

Enable Two-Factor Authentication

- Use an authenticator app like Google Authenticator or Authy to generate time-based codes for enhanced security. + Use an authenticator app like Google Authenticator or Authy to generate time-based codes for + enhanced security.

-
+
- +
@@ -155,11 +157,11 @@ Enter the 6-digit code from your app to complete setup - +
- +

Scan this QR code with your authenticator app @@ -167,13 +169,13 @@

- +
- +
-
@@ -198,12 +200,12 @@
- -
- + -
- - Or continue with email - +
+ Or continue with email
- +
- + Forgot your password?
-
-
- +
Don't have an account? - - Sign up - + Sign up
- + -
+
{{ error }}
- By clicking continue, you agree to our + By clicking continue, you agree to our Terms of Service - and + and Privacy Policy.
@@ -101,7 +98,7 @@ \ No newline at end of file + diff --git a/pages/p/[orgSlug]/[projectSlug].vue b/pages/p/[orgSlug]/[projectSlug].vue new file mode 100644 index 0000000..cd4bf6d --- /dev/null +++ b/pages/p/[orgSlug]/[projectSlug].vue @@ -0,0 +1,476 @@ +