diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..266baa3 --- /dev/null +++ b/.env.test @@ -0,0 +1,6 @@ +AUTH_SECRET="auth_secret" +AUTH_URL="http://127.0.0.1:3000" +HOSTNAME="127.0.0.1" +DATABASE_URL="file:/tmp/dev.db" +AUTH_AUTHENTIK_ID="id" +AUTH_AUTHENTIK_ISSUER="issuer" \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..b0d8710 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,34 @@ +name: tests +on: + push: + branches: + - main + - renovate/* + pull_request: +jobs: + tests: + name: Tests + runs-on: docker + container: + image: cypress/browsers:latest@sha256:9daea41366dfd1b72496bf3e8295eda215a6990c2dbe4f9ff4b8ba47342864fb + options: --user 1001 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Enable corepack + run: corepack enable + + - name: Cypress run (e2e) + uses: https://github.com/cypress-io/github-action@v6 + with: + build: yarn cypress:build + start: yarn cypress:start_server + e2e: true + wait-on: 'http://127.0.0.1:3000' + + - name: Cypress run (component) + uses: https://github.com/cypress-io/github-action@v6 + with: + component: true + install: false diff --git a/.gitignore b/.gitignore index cda64ee..03ddb54 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* !.env.example +!.env.test # vercel .vercel @@ -45,3 +46,8 @@ next-env.d.ts /prisma/*.db* src/generated/* data + +# cypress +cypress/videos +cypress/screenshots +cypress/coverage diff --git a/Dockerfile b/Dockerfile index b60e118..29e8dfa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-alpine@sha256:41e4389f3d988d2ed55392df4db1420ad048ae53324a8e2b7c6d19508288107e AS base +FROM node:22-alpine@sha256:5340cbfc2df14331ab021555fdd9f83f072ce811488e705b0e736b11adeec4bb AS base # ----- Dependencies ----- FROM base AS deps diff --git a/Dockerfile.dev b/Dockerfile.dev index 4467c5f..a77f9a8 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM node:22-alpine@sha256:41e4389f3d988d2ed55392df4db1420ad048ae53324a8e2b7c6d19508288107e +FROM node:22-alpine@sha256:5340cbfc2df14331ab021555fdd9f83f072ce811488e705b0e736b11adeec4bb WORKDIR /app diff --git a/README.md b/README.md index d9ca71b..56fa41d 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,6 @@ This project is built with a modern tech stack: yarn install ``` 3. **Set up environment variables:** - - You will need to create an `AUTH_SECRET`. You can generate one using the following command: ```bash npx auth secret @@ -97,7 +96,6 @@ This project is built with a modern tech stack: ``` 4. **Apply database migrations (Prisma):** - - Ensure your Prisma schema (`prisma/schema.prisma`) is defined. - Setup/update the database with these commands: ```bash diff --git a/cypress.config.ts b/cypress.config.ts new file mode 100644 index 0000000..bebdaa5 --- /dev/null +++ b/cypress.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'cypress'; + +export default defineConfig({ + component: { + devServer: { + framework: 'next', + bundler: 'webpack', + }, + }, + + e2e: { + setupNodeEvents(on, config) { + // implement node event listeners here + }, + }, +}); diff --git a/cypress/e2e/auth-user.ts b/cypress/e2e/auth-user.ts new file mode 100644 index 0000000..5b02ab9 --- /dev/null +++ b/cypress/e2e/auth-user.ts @@ -0,0 +1,12 @@ +export default function authUser() { + cy.visit('http://127.0.0.1:3000/login'); + cy.getBySel('login-header').should('exist'); + cy.getBySel('login-form').should('exist'); + cy.getBySel('email-input').should('exist'); + cy.getBySel('password-input').should('exist'); + cy.getBySel('login-button').should('exist'); + cy.getBySel('email-input').type('cypress@example.com'); + cy.getBySel('password-input').type('Password123!'); + cy.getBySel('login-button').click(); + cy.url().should('include', '/home'); +} diff --git a/cypress/e2e/event-create.cy.ts b/cypress/e2e/event-create.cy.ts new file mode 100644 index 0000000..a74f770 --- /dev/null +++ b/cypress/e2e/event-create.cy.ts @@ -0,0 +1,9 @@ +import authUser from './auth-user'; + +describe('event creation', () => { + it('loads', () => { + authUser(); + + // cy.visit('http://127.0.0.1:3000/events/new'); // TODO: Add event creation tests + }); +}); diff --git a/cypress/e2e/login.cy.ts b/cypress/e2e/login.cy.ts new file mode 100644 index 0000000..d9461d1 --- /dev/null +++ b/cypress/e2e/login.cy.ts @@ -0,0 +1,45 @@ +describe('login and register', () => { + it('loads', () => { + cy.visit('http://127.0.0.1:3000/'); + + cy.getBySel('login-header').should('exist'); + }); + + it('shows register form', () => { + cy.visit('http://127.0.0.1:3000/'); + + cy.getBySel('register-switch').click(); + + cy.getBySel('register-form').should('exist'); + cy.getBySel('first-name-input').should('exist'); + cy.getBySel('last-name-input').should('exist'); + cy.getBySel('email-input').should('exist'); + cy.getBySel('username-input').should('exist'); + cy.getBySel('password-input').should('exist'); + cy.getBySel('confirm-password-input').should('exist'); + cy.getBySel('register-button').should('exist'); + }); + + it('allows to register', async () => { + cy.visit('http://127.0.0.1:3000/'); + + cy.getBySel('register-switch').click(); + + cy.getBySel('first-name-input').type('Test'); + cy.getBySel('last-name-input').type('User'); + cy.getBySel('email-input').type('test@example.com'); + cy.getBySel('username-input').type('testuser'); + cy.getBySel('password-input').type('Password123!'); + cy.getBySel('confirm-password-input').type('Password123!'); + cy.getBySel('register-button').click(); + cy.getBySel('login-header').should('exist'); + cy.getBySel('login-form').should('exist'); + cy.getBySel('email-input').should('exist'); + cy.getBySel('password-input').should('exist'); + cy.getBySel('login-button').should('exist'); + cy.getBySel('email-input').type('test@example.com'); + cy.getBySel('password-input').type('Password123!'); + cy.getBySel('login-button').click(); + cy.url().should('include', '/home'); + }); +}); diff --git a/cypress/e2e/seed.ts b/cypress/e2e/seed.ts new file mode 100644 index 0000000..c3cd389 --- /dev/null +++ b/cypress/e2e/seed.ts @@ -0,0 +1,29 @@ +import { PrismaClient } from '../../src/generated/prisma'; + +const prisma = new PrismaClient(); + +export default async function requireUser() { + await prisma.$transaction(async (tx) => { + const { id } = await tx.user.create({ + data: { + email: 'cypress@example.com', + name: 'cypress', + password_hash: + '$2a$10$FmkVRHXzMb63dLHHwG1mDOepZJirL.U964wU/3Xr7cFis8XdRh8sO', + first_name: 'Cypress', + last_name: 'Tester', + emailVerified: new Date(), + }, + }); + + await tx.account.create({ + data: { + userId: id, + type: 'credentials', + provider: 'credentials', + providerAccountId: id, + }, + }); + }); +} +requireUser(); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts new file mode 100644 index 0000000..59717f5 --- /dev/null +++ b/cypress/support/commands.ts @@ -0,0 +1,62 @@ +/// +// *********************************************** +// This example commands.ts shows you how to +// create various custom commands and overwrite +// existing commands. +// +// For more comprehensive examples of custom +// commands please read more here: +// https://on.cypress.io/custom-commands +// *********************************************** +// +// +// -- This is a parent command -- +// Cypress.Commands.add('login', (email, password) => { ... }) +// +// +// -- This is a child command -- +// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) +// +// +// -- This is a dual command -- +// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) +// +// +// -- This will overwrite an existing command -- +// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) +// +// declare global { +// namespace Cypress { +// interface Chainable { +// login(email: string, password: string): Chainable +// drag(subject: string, options?: Partial): Chainable +// dismiss(subject: string, options?: Partial): Chainable +// visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable +// } +// } +// } + +Cypress.Commands.add('getBySel', (selector, ...args) => { + return cy.get(`[data-cy=${selector}]`, ...args); +}); + +Cypress.Commands.add('getBySelLike', (selector, ...args) => { + return cy.get(`[data-cy*=${selector}]`, ...args); +}); + +declare global { + namespace Cypress { + interface Chainable { + getBySel( + selector: string, + ...args: any[] + ): Chainable>; + getBySelLike( + selector: string, + ...args: any[] + ): Chainable>; + } + } +} + +export {}; diff --git a/cypress/support/component-index.html b/cypress/support/component-index.html new file mode 100644 index 0000000..2cbfac6 --- /dev/null +++ b/cypress/support/component-index.html @@ -0,0 +1,14 @@ + + + + + + + Components App + + + + + + + diff --git a/cypress/support/component.ts b/cypress/support/component.ts new file mode 100644 index 0000000..b1f1c92 --- /dev/null +++ b/cypress/support/component.ts @@ -0,0 +1,38 @@ +// *********************************************************** +// This example support/component.ts is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +import '@/app/globals.css'; + +// Import commands.js using ES2015 syntax: +import './commands'; + +import { mount } from 'cypress/react'; + +// Augment the Cypress namespace to include type definitions for +// your custom command. +// Alternatively, can be defined in cypress/support/component.d.ts +// with a at the top of your spec. +declare global { + namespace Cypress { + interface Chainable { + mount: typeof mount; + } + } +} + +Cypress.Commands.add('mount', mount); + +// Example use: +// cy.mount() diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts new file mode 100644 index 0000000..e66558e --- /dev/null +++ b/cypress/support/e2e.ts @@ -0,0 +1,17 @@ +// *********************************************************** +// This example support/e2e.ts is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import './commands'; diff --git a/exportSwagger.ts b/exportSwagger.ts index 1eb2837..6b6df1e 100644 --- a/exportSwagger.ts +++ b/exportSwagger.ts @@ -22,16 +22,15 @@ async function exportSwagger() { ); await Promise.all( - filesToImport.map((file) => { - return import(file) - .then((module) => { - if (module.default) { - module.default(registry); - } - }) - .catch((error) => { - console.error(`Error importing ${file}:`, error); - }); + filesToImport.map(async (file) => { + try { + const moduleImp = await import(file); + if (moduleImp.default) { + moduleImp.default(registry); + } + } catch (error) { + console.error(`Error importing ${file}:`, error); + } }), ); diff --git a/package.json b/package.json index c5c77fb..9a33a39 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "next dev --turbopack", "build": "prettier --check . && next build", - "start": "next start", + "start": "node .next/standalone/server.js", "lint": "next lint", "format": "prettier --write .", "prisma:migrate": "dotenv -e .env.local -- prisma migrate dev", @@ -15,7 +15,11 @@ "prisma:migrate:reset": "dotenv -e .env.local -- prisma migrate reset", "dev_container": "docker compose -f docker-compose.dev.yml up --watch --build", "swagger:generate": "ts-node -r tsconfig-paths/register exportSwagger.ts", - "orval:generate": "orval" + "orval:generate": "orval", + "cypress:build": "rm -rf /tmp/dev.db && DATABASE_URL=\"file:/tmp/dev.db\" yarn prisma:generate && yarn swagger:generate && yarn orval:generate && DATABASE_URL=\"file:/tmp/dev.db\" yarn prisma:db:push && prettier --check . && NODE_ENV=test next build", + "cypress:start_server": "DATABASE_URL=\"file:/tmp/dev.db\" ts-node cypress/e2e/seed.ts && cp .env.test .next/standalone && cp public .next/standalone/ -r && cp .next/static/ .next/standalone/.next/ -r && NODE_ENV=test HOSTNAME=\"0.0.0.0\" dotenv -e .env.test -- node .next/standalone/server.js", + "cypress:open": "cypress open", + "cypress:run": "cypress run" }, "dependencies": { "@asteasolutions/zod-to-openapi": "^8.0.0-beta.4", @@ -27,47 +31,63 @@ "@fortawesome/react-fontawesome": "^0.2.2", "@hookform/resolvers": "^5.0.1", "@prisma/client": "^6.9.0", - "@radix-ui/react-dropdown-menu": "^2.1.14", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-collapsible": "^1.1.11", + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-hover-card": "^1.1.13", "@radix-ui/react-label": "^2.1.6", - "@radix-ui/react-scroll-area": "^1.2.8", + "@radix-ui/react-popover": "^1.1.14", + "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "^2.2.4", - "@radix-ui/react-separator": "^1.1.6", - "@radix-ui/react-slot": "^1.2.2", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.4", "@radix-ui/react-tabs": "^1.1.11", + "@radix-ui/react-tooltip": "^1.2.7", "@tanstack/react-query": "^5.80.7", "bcryptjs": "^3.0.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "lucide-react": "^0.511.0", - "next": "15.4.0-canary.92", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", + "lucide-react": "^0.523.0", + "next": "15.3.4", "next-auth": "^5.0.0-beta.25", + "next-swagger-doc": "^0.4.1", "next-themes": "^0.4.6", - "react": "^19.0.0", + "react": "^19.1.0", + "react-big-calendar": "^1.18.0", + "react-datepicker": "^8.4.0", + "react-day-picker": "^9.7.0", "react-dom": "^19.0.0", + "react-error-boundary": "^6.0.0", "react-hook-form": "^7.56.4", + "sonner": "^2.0.5", "swagger-ui-react": "^5.24.1", "tailwind-merge": "^3.2.0", - "zod": "^3.25.60" + "zod": "^3.25.60", + "zod-validation-error": "^3.5.2" }, "devDependencies": { "@eslint/eslintrc": "3.3.1", - "@tailwindcss/postcss": "4.1.10", - "@types/node": "22.15.32", + "@tailwindcss/postcss": "4.1.11", + "@types/node": "22.15.33", "@types/react": "19.1.8", + "@types/react-big-calendar": "1.16.2", "@types/react-dom": "19.1.6", - "@types/swagger-ui-react": "5", + "@types/swagger-ui-react": "5.18.0", "@types/webpack-env": "1.18.8", + "cypress": "14.5.0", "dotenv-cli": "8.0.0", "eslint": "9.29.0", "eslint-config-next": "15.3.4", "eslint-config-prettier": "10.1.5", "orval": "7.10.0", "postcss": "8.5.6", - "prettier": "3.5.3", + "prettier": "3.6.2", "prisma": "6.10.1", - "tailwindcss": "4.1.10", + "tailwindcss": "4.1.11", "ts-node": "10.9.2", "tsconfig-paths": "4.2.0", "tw-animate-css": "1.3.4", diff --git a/src/app/(main)/events/[eventID]/page.tsx b/src/app/(main)/events/[eventID]/page.tsx new file mode 100644 index 0000000..bfc390d --- /dev/null +++ b/src/app/(main)/events/[eventID]/page.tsx @@ -0,0 +1,238 @@ +'use client'; + +import React, { useState } from 'react'; +import Logo from '@/components/misc/logo'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { + useDeleteApiEventEventID, + useGetApiEventEventID, +} from '@/generated/api/event/event'; +import { useGetApiUserMe } from '@/generated/api/user/user'; +import { RedirectButton } from '@/components/buttons/redirect-button'; +import { useSession } from 'next-auth/react'; +import ParticipantListEntry from '@/components/custom-ui/participant-list-entry'; +import { useParams, useRouter } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { ToastInner } from '@/components/misc/toast-inner'; +import { toast } from 'sonner'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; + +export default function ShowEvent() { + const session = useSession(); + const router = useRouter(); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + const { eventID: eventID } = useParams<{ eventID: string }>(); + + // Fetch event data + const { data: eventData, isLoading, error } = useGetApiEventEventID(eventID); + const { data: userData, isLoading: userLoading } = useGetApiUserMe(); + const deleteEvent = useDeleteApiEventEventID(); + + if (isLoading || userLoading) { + return ( + + Loading... + + ); + } + if (error || !eventData?.data?.event) { + return ( + + Error loading event. + + ); + } + + const event = eventData.data.event; + const organiserName = userData?.data.user?.name || 'Unknown User'; + + // Format dates & times for display + const formatDate = (isoString?: string) => { + if (!isoString) return '-'; + return new Date(isoString).toLocaleDateString(); + }; + const formatTime = (isoString?: string) => { + if (!isoString) return '-'; + return new Date(isoString).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); + }; + + return ( + + + + + + + + + + + + + + {event.title || 'Untitled Event'} + + + + + + + + start Time + + + {event.start_time + ? `${formatDate(event.start_time)} ${formatTime(event.start_time)}` + : '-'} + + + + + end Time + + + {event.end_time + ? `${formatDate(event.end_time)} ${formatTime(event.end_time)}` + : '-'} + + + + + Location + + {event.location || '-'} + + + + + created: + + + {event.created_at ? formatDate(event.created_at) : '-'} + + + + + updated: + + + {event.updated_at ? formatDate(event.updated_at) : '-'} + + + + + + + + + + Organiser: + + {organiserName} + + + + + Description + + {event.description || '-'} + + + + + Participants + {' '} + + {event.participants?.map((user) => ( + + ))} + + + + + + + {session.data?.user?.id === event.organizer.id ? ( + + + + delete + + + + + Delete Event + + Are you sure you want to delete the event “ + {event.title}”? This action cannot be undone. + + + + setDeleteDialogOpen(false)} + > + Cancel + + { + deleteEvent.mutate( + { eventID: event.id }, + { + onSuccess: () => { + router.push('/home'); + toast.custom((t) => ( + + )); + }, + }, + ); + setDeleteDialogOpen(false); + }} + > + Delete + + + + + ) : null} + + + {session.data?.user?.id === event.organizer.id ? ( + + ) : null} + + + + + + + + ); +} diff --git a/src/app/(main)/events/edit/[eventID]/page.tsx b/src/app/(main)/events/edit/[eventID]/page.tsx new file mode 100644 index 0000000..42c6e8b --- /dev/null +++ b/src/app/(main)/events/edit/[eventID]/page.tsx @@ -0,0 +1,24 @@ +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import EventForm from '@/components/forms/event-form'; +import { Suspense } from 'react'; + +export default async function Page({ + params, +}: { + params: Promise<{ eventID: string }>; +}) { + const eventID = (await params).eventID; + return ( + + + + + + + + + + + + ); +} diff --git a/src/app/(main)/events/new/page.tsx b/src/app/(main)/events/new/page.tsx new file mode 100644 index 0000000..2db7ae2 --- /dev/null +++ b/src/app/(main)/events/new/page.tsx @@ -0,0 +1,21 @@ +import { ThemePicker } from '@/components/misc/theme-picker'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import EventForm from '@/components/forms/event-form'; +import { Suspense } from 'react'; + +export default function NewEvent() { + return ( + + {} + + + + + + + + + + + ); +} diff --git a/src/app/(main)/events/page.tsx b/src/app/(main)/events/page.tsx new file mode 100644 index 0000000..f0391dd --- /dev/null +++ b/src/app/(main)/events/page.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { RedirectButton } from '@/components/buttons/redirect-button'; +import EventListEntry from '@/components/custom-ui/event-list-entry'; +import { Label } from '@/components/ui/label'; +import { useGetApiEvent } from '@/generated/api/event/event'; + +export default function Events() { + const { data: eventsData, isLoading, error } = useGetApiEvent(); + + if (isLoading) return Loading...; + if (error) + return ( + Error loading events + ); + + const events = eventsData?.data?.events || []; + + return ( + + {/* Heading */} + + My Events + + + {/* Scrollable event list */} + + + {events.length > 0 ? ( + events.map((event) => ( + + )) + ) : ( + + + You don't have any events right now + + + + )} + + + + ); +} diff --git a/src/app/(main)/home/page.tsx b/src/app/(main)/home/page.tsx new file mode 100644 index 0000000..1cf8a90 --- /dev/null +++ b/src/app/(main)/home/page.tsx @@ -0,0 +1,17 @@ +'use client'; + +import Calendar from '@/components/calendar'; +import { useGetApiUserMe } from '@/generated/api/user/user'; + +export default function Home() { + const { data } = useGetApiUserMe(); + + return ( + + + + ); +} diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx new file mode 100644 index 0000000..7106e70 --- /dev/null +++ b/src/app/(main)/layout.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { cookies } from 'next/headers'; + +import { AppSidebar } from '@/components/custom-ui/app-sidebar'; +import SidebarProviderWrapper from '@/components/wrappers/sidebar-provider'; +import Header from '@/components/misc/header'; + +export default async function Layout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const cookieStore = await cookies(); + const defaultOpen = cookieStore.get('sidebar_state')?.value === 'true'; + return ( + <> + + + {children} + + > + ); +} diff --git a/src/app/api/user/[user]/calendar/route.ts b/src/app/api/user/[user]/calendar/route.ts index f6b6098..62142e9 100644 --- a/src/app/api/user/[user]/calendar/route.ts +++ b/src/app/api/user/[user]/calendar/route.ts @@ -136,15 +136,15 @@ export const GET = auth(async function GET(req, { params }) { start_time: 'asc', }, select: { - id: requestUserId === requestedUserId ? true : false, - reason: requestUserId === requestedUserId ? true : false, + id: true, + reason: true, start_time: true, end_time: true, - is_recurring: requestUserId === requestedUserId ? true : false, - recurrence_end_date: requestUserId === requestedUserId ? true : false, - rrule: requestUserId === requestedUserId ? true : false, - created_at: requestUserId === requestedUserId ? true : false, - updated_at: requestUserId === requestedUserId ? true : false, + is_recurring: true, + recurrence_end_date: true, + rrule: true, + created_at: true, + updated_at: true, }, }, }, @@ -167,6 +167,7 @@ export const GET = auth(async function GET(req, { params }) { calendar.push({ ...event.meeting, type: 'event' }); } else { calendar.push({ + id: event.meeting.id, start_time: event.meeting.start_time, end_time: event.meeting.end_time, type: 'blocked_private', @@ -182,6 +183,7 @@ export const GET = auth(async function GET(req, { params }) { calendar.push({ ...event, type: 'event' }); } else { calendar.push({ + id: event.id, start_time: event.start_time, end_time: event.end_time, type: 'blocked_private', @@ -190,23 +192,35 @@ export const GET = auth(async function GET(req, { params }) { } for (const slot of requestedUser.blockedSlots) { - calendar.push({ - start_time: slot.start_time, - end_time: slot.end_time, - id: slot.id, - reason: slot.reason, - is_recurring: slot.is_recurring, - recurrence_end_date: slot.recurrence_end_date, - rrule: slot.rrule, - created_at: slot.created_at, - updated_at: slot.updated_at, - type: - requestUserId === requestedUserId ? 'blocked_owned' : 'blocked_private', - }); + if (requestUserId === requestedUserId) { + calendar.push({ + start_time: slot.start_time, + end_time: slot.end_time, + id: slot.id, + reason: slot.reason, + is_recurring: slot.is_recurring, + recurrence_end_date: slot.recurrence_end_date, + rrule: slot.rrule, + created_at: slot.created_at, + updated_at: slot.updated_at, + type: 'blocked_owned', + }); + } else { + calendar.push({ + start_time: slot.start_time, + end_time: slot.end_time, + id: slot.id, + type: 'blocked_private', + }); + } } return returnZodTypeCheckedResponse(UserCalendarResponseSchema, { success: true, - calendar, + calendar: calendar.filter( + (event, index, self) => + self.findIndex((e) => e.id === event.id && e.type === event.type) === + index, + ), }); }); diff --git a/src/app/api/user/[user]/calendar/validation.ts b/src/app/api/user/[user]/calendar/validation.ts index a0d179f..1572793 100644 --- a/src/app/api/user/[user]/calendar/validation.ts +++ b/src/app/api/user/[user]/calendar/validation.ts @@ -13,23 +13,28 @@ export const BlockedSlotSchema = zod start_time: eventStartTimeSchema, end_time: eventEndTimeSchema, type: zod.literal('blocked_private'), + id: zod.string(), }) .openapi('BlockedSlotSchema', { description: 'Blocked time slot in the user calendar', }); -export const OwnedBlockedSlotSchema = BlockedSlotSchema.extend({ - id: zod.string(), - reason: zod.string().nullish(), - is_recurring: zod.boolean().default(false), - recurrence_end_date: zod.date().nullish(), - rrule: zod.string().nullish(), - created_at: zod.date().nullish(), - updated_at: zod.date().nullish(), - type: zod.literal('blocked_owned'), -}).openapi('OwnedBlockedSlotSchema', { - description: 'Blocked slot owned by the user', -}); +export const OwnedBlockedSlotSchema = zod + .object({ + start_time: eventStartTimeSchema, + end_time: eventEndTimeSchema, + id: zod.string(), + reason: zod.string().nullish(), + is_recurring: zod.boolean().default(false), + recurrence_end_date: zod.date().nullish(), + rrule: zod.string().nullish(), + created_at: zod.date().nullish(), + updated_at: zod.date().nullish(), + type: zod.literal('blocked_owned'), + }) + .openapi('OwnedBlockedSlotSchema', { + description: 'Blocked slot owned by the user', + }); export const VisibleSlotSchema = EventSchema.omit({ organizer: true, diff --git a/src/app/api/user/me/password/route.ts b/src/app/api/user/me/password/route.ts new file mode 100644 index 0000000..0b92559 --- /dev/null +++ b/src/app/api/user/me/password/route.ts @@ -0,0 +1,122 @@ +import { auth } from '@/auth'; +import { prisma } from '@/prisma'; +import { updateUserPasswordServerSchema } from '../validation'; +import { + returnZodTypeCheckedResponse, + userAuthenticated, +} from '@/lib/apiHelpers'; +import { FullUserResponseSchema } from '../../validation'; +import { + ErrorResponseSchema, + ZodErrorResponseSchema, +} from '@/app/api/validation'; +import bcrypt from 'bcryptjs'; + +export const PATCH = auth(async function PATCH(req) { + const authCheck = userAuthenticated(req); + if (!authCheck.continue) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + authCheck.response, + authCheck.metadata, + ); + + const body = await req.json(); + const parsedBody = updateUserPasswordServerSchema.safeParse(body); + if (!parsedBody.success) + return returnZodTypeCheckedResponse( + ZodErrorResponseSchema, + { + success: false, + message: 'Invalid request data', + errors: parsedBody.error.issues, + }, + { status: 400 }, + ); + + const { current_password, new_password } = parsedBody.data; + + const dbUser = await prisma.user.findUnique({ + where: { + id: authCheck.user.id, + }, + include: { + accounts: true, + }, + }); + + if (!dbUser) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + { + success: false, + message: 'User not found', + }, + { status: 404 }, + ); + + if (!dbUser.password_hash) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + { + success: false, + message: 'User does not have a password set', + }, + { status: 400 }, + ); + + if ( + dbUser.accounts.length === 0 || + dbUser.accounts[0].provider !== 'credentials' + ) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + { + success: false, + message: 'Credentials login is not enabled for this user', + }, + { status: 400 }, + ); + + const isCurrentPasswordValid = await bcrypt.compare( + current_password, + dbUser.password_hash || '', + ); + + if (!isCurrentPasswordValid) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + { + success: false, + message: 'Current password is incorrect', + }, + { status: 401 }, + ); + + const hashedNewPassword = await bcrypt.hash(new_password, 10); + + const updatedUser = await prisma.user.update({ + where: { + id: dbUser.id, + }, + data: { + password_hash: hashedNewPassword, + }, + select: { + id: true, + name: true, + first_name: true, + last_name: true, + email: true, + image: true, + timezone: true, + created_at: true, + updated_at: true, + }, + }); + + return returnZodTypeCheckedResponse(FullUserResponseSchema, { + success: true, + user: updatedUser, + }); +}); diff --git a/src/app/api/user/me/password/swagger.ts b/src/app/api/user/me/password/swagger.ts new file mode 100644 index 0000000..0bc62f0 --- /dev/null +++ b/src/app/api/user/me/password/swagger.ts @@ -0,0 +1,43 @@ +import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi'; +import { FullUserResponseSchema } from '../../validation'; +import { updateUserPasswordServerSchema } from '../validation'; +import { + invalidRequestDataResponse, + notAuthenticatedResponse, + serverReturnedDataValidationErrorResponse, + userNotFoundResponse, +} from '@/lib/defaultApiResponses'; + +export default function registerSwaggerPaths(registry: OpenAPIRegistry) { + registry.registerPath({ + method: 'patch', + path: '/api/user/me/password', + description: 'Update the password of the currently authenticated user', + request: { + body: { + description: 'User password update request body', + required: true, + content: { + 'application/json': { + schema: updateUserPasswordServerSchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'User information updated successfully', + content: { + 'application/json': { + schema: FullUserResponseSchema, + }, + }, + }, + ...invalidRequestDataResponse, + ...notAuthenticatedResponse, + ...userNotFoundResponse, + ...serverReturnedDataValidationErrorResponse, + }, + tags: ['User'], + }); +} diff --git a/src/app/api/user/me/route.ts b/src/app/api/user/me/route.ts index 5ba9792..5571a6b 100644 --- a/src/app/api/user/me/route.ts +++ b/src/app/api/user/me/route.ts @@ -8,6 +8,7 @@ import { import { FullUserResponseSchema } from '../validation'; import { ErrorResponseSchema, + SuccessResponseSchema, ZodErrorResponseSchema, } from '@/app/api/validation'; @@ -117,3 +118,43 @@ export const PATCH = auth(async function PATCH(req) { { status: 200 }, ); }); + +export const DELETE = auth(async function DELETE(req) { + const authCheck = userAuthenticated(req); + if (!authCheck.continue) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + authCheck.response, + authCheck.metadata, + ); + + const dbUser = await prisma.user.findUnique({ + where: { + id: authCheck.user.id, + }, + }); + if (!dbUser) + return returnZodTypeCheckedResponse( + ErrorResponseSchema, + { + success: false, + message: 'User not found', + }, + { status: 404 }, + ); + + await prisma.user.delete({ + where: { + id: authCheck.user.id, + }, + }); + + return returnZodTypeCheckedResponse( + SuccessResponseSchema, + { + success: true, + message: 'User deleted successfully', + }, + { status: 200 }, + ); +}); diff --git a/src/app/api/user/me/swagger.ts b/src/app/api/user/me/swagger.ts index e0a36a1..6a9e375 100644 --- a/src/app/api/user/me/swagger.ts +++ b/src/app/api/user/me/swagger.ts @@ -7,6 +7,7 @@ import { serverReturnedDataValidationErrorResponse, userNotFoundResponse, } from '@/lib/defaultApiResponses'; +import { SuccessResponseSchema } from '../../validation'; export default function registerSwaggerPaths(registry: OpenAPIRegistry) { registry.registerPath({ @@ -60,4 +61,24 @@ export default function registerSwaggerPaths(registry: OpenAPIRegistry) { }, tags: ['User'], }); + + registry.registerPath({ + method: 'delete', + path: '/api/user/me', + description: 'Delete the currently authenticated user', + responses: { + 200: { + description: 'User deleted successfully', + content: { + 'application/json': { + schema: SuccessResponseSchema, + }, + }, + }, + ...notAuthenticatedResponse, + ...userNotFoundResponse, + ...serverReturnedDataValidationErrorResponse, + }, + tags: ['User'], + }); } diff --git a/src/app/api/user/me/validation.ts b/src/app/api/user/me/validation.ts index 49c6219..66f07cc 100644 --- a/src/app/api/user/me/validation.ts +++ b/src/app/api/user/me/validation.ts @@ -4,6 +4,8 @@ import { lastNameSchema, newUserEmailServerSchema, newUserNameServerSchema, + passwordSchema, + timezoneSchema, } from '@/app/api/user/validation'; // ---------------------------------------- @@ -16,6 +18,16 @@ export const updateUserServerSchema = zod.object({ first_name: firstNameSchema.optional(), last_name: lastNameSchema.optional(), email: newUserEmailServerSchema.optional(), - image: zod.string().optional(), - timezone: zod.string().optional(), + image: zod.url().optional(), + timezone: timezoneSchema.optional(), }); + +export const updateUserPasswordServerSchema = zod + .object({ + current_password: zod.string().min(1, 'Current password is required'), + new_password: passwordSchema, + confirm_new_password: passwordSchema, + }) + .refine((data) => data.new_password === data.confirm_new_password, { + message: 'New password and confirm new password must match', + }); diff --git a/src/app/api/user/validation.ts b/src/app/api/user/validation.ts index 79b1e7e..89b8ba4 100644 --- a/src/app/api/user/validation.ts +++ b/src/app/api/user/validation.ts @@ -1,6 +1,7 @@ import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; import { prisma } from '@/prisma'; import zod from 'zod/v4'; +import { allTimeZones } from '@/lib/timezones'; extendZodWithOpenApi(zod); @@ -107,6 +108,15 @@ export const passwordSchema = zod 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character', ); +// ---------------------------------------- +// +// Timezone Validation +// +// ---------------------------------------- +export const timezoneSchema = zod.enum(allTimeZones).openapi('Timezone', { + description: 'Valid timezone from the list of supported timezones', +}); + // ---------------------------------------- // // User Schema Validation (for API responses) @@ -119,8 +129,11 @@ export const FullUserSchema = zod first_name: zod.string().nullish(), last_name: zod.string().nullish(), email: zod.email(), - image: zod.string().nullish(), - timezone: zod.string(), + image: zod.url().nullish(), + timezone: zod + .string() + .refine((i) => (allTimeZones as string[]).includes(i)) + .nullish(), created_at: zod.date(), updated_at: zod.date(), }) diff --git a/src/app/globals.css b/src/app/globals.css index f85cb2f..bc18178 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -9,6 +9,7 @@ --font-heading: 'Comfortaa', sans-serif; --font-label: 'Varela Round', sans-serif; --font-button: 'Varela Round', sans-serif; + --font-sans: var(--font-label); --transparent: transparent; @@ -28,11 +29,12 @@ --background: var(--neutral-800); --background-reversed: var(--neutral-000); - --base: var(--neutral-800); + --basecl: var(--neutral-800); --text: var(--neutral-000); --text-alt: var(--neutral-900); --text-input: var(--text); --text-muted-input: var(--neutral-450); + --text-muted: var(--neutral-300); --muted-input: var(--neutral-600); --background-disabled: var(--neutral-500); --text-disabled: var(--neutral-700); @@ -48,13 +50,27 @@ --active-secondary: oklch(0.4254 0.133 272.15); --disabled-secondary: oklch(0.4937 0.1697 271.26 / 0.5); + --destructive: oklch(60.699% 0.20755 25.945); + --hover-destructive: oklch(60.699% 0.20755 25.945 / 0.8); + --active-destructive: oklch(50.329% 0.17084 25.842); + --disabled-destructive: oklch(60.699% 0.20755 25.945 / 0.4); + --muted: var(--color-neutral-700); --hover-muted: var(--color-neutral-600); --active-muted: var(--color-neutral-400); --disabled-muted: var(--color-neutral-400); + --toaster-default-bg: var(--color-neutral-150); + --toaster-success-bg: oklch(54.147% 0.09184 144.208); + --toaster-error-bg: oklch(52.841% 0.10236 27.274); + --toaster-info-bg: oklch(44.298% 0.05515 259.369); + --toaster-warning-bg: oklch(61.891% 0.07539 102.943); + --toaster-notification-bg: var(--color-neutral-150); + --card: var(--neutral-800); + --sidebar-width-icon: 32px; + /* ------------------- */ --foreground: oklch(0.13 0.028 261.692); @@ -77,8 +93,6 @@ --accent-foreground: oklch(0.21 0.034 264.665); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.928 0.006 264.531); --input: oklch(0.928 0.006 264.531); @@ -95,23 +109,79 @@ --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0.002 247.839); + --sidebar: var(--background); - --sidebar-foreground: oklch(0.13 0.028 261.692); + --sidebar-foreground: var(--text); --sidebar-primary: oklch(0.21 0.034 264.665); - --sidebar-primary-foreground: oklch(0.985 0.002 247.839); + --sidebar-primary-foreground: var(--text); --sidebar-accent: oklch(0.967 0.003 264.542); - --sidebar-accent-foreground: oklch(0.21 0.034 264.665); + --sidebar-accent-foreground: var(--text); --sidebar-border: oklch(0.928 0.006 264.531); --sidebar-ring: oklch(0.707 0.022 261.325); } +h1 { + font-family: var(--font-heading); + font-size: 40px; + font-style: normal; + font-weight: 700; + line-height: normal; +} + +h2 { + font-family: var(--font-heading); + font-size: 36px; + font-style: normal; + font-weight: 600; + line-height: normal; +} + +h3 { + font-family: var(--font-heading); + font-size: 32px; + font-style: normal; + font-weight: 600; + line-height: normal; +} + +h4 { + font-family: var(--font-heading); + font-size: 28px; + font-style: normal; + font-weight: 600; + line-height: normal; +} + +h5 { + font-family: var(--font-heading); + font-size: 26px; + font-style: normal; + font-weight: 600; + line-height: normal; +} + +h6 { + font-family: var(--font-heading); + font-size: 20px; + font-style: normal; + font-weight: 600; + line-height: normal; +} + +p { + font-family: var(--font-label); + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: normal; +} + @font-face { font-family: 'Comfortaa'; font-style: normal; @@ -150,11 +220,12 @@ --color-background: var(--neutral-750); --color-background-reversed: var(--background-reversed); - --color-base: var(--neutral-800); + --color-basecl: var(--neutral-800); --color-text: var(--text); --color-text-alt: var(--text-alt); --color-text-input: var(--text-input); --color-text-muted-input: var(--text-muted-input); + --color-text-muted: var(--text-muted); --color-muted-input: var(--muted-input); --color-background-disabled: var(--neutral-500); @@ -171,11 +242,23 @@ --color-active-secondary: var(--active-secondary); --color-disabled-secondary: var(--disabled-secondary); + --color-destructive: var(--destructive); + --color-hover-destructive: var(--hover-destructive); + --color-active-destructive: var(--active-destructive); + --color-disabled-destructive: var(--disabled-destructive); + --color-muted: var(--muted); --color-hover-muted: var(--hover-muted); --color-active-muted: var(--active-muted); --color-disabled-muted: var(--disabled-muted); + --color-toaster-default-bg: var(--toaster-default-bg); + --color-toaster-success-bg: var(--toaster-success-bg); + --color-toaster-error-bg: var(--toaster-error-bg); + --color-toaster-info-bg: var(--toaster-info-bg); + --color-toaster-warning-bg: var(--toaster-warning-bg); + --color-toaster-notification-bg: var(--toaster-notification-bg); + /* Custom values */ --radius-sm: calc(var(--radius) - 4px); @@ -216,8 +299,6 @@ --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-border: var(--border); --color-input: var(--input); @@ -273,11 +354,12 @@ --background: var(--neutral-750); --background-reversed: var(--neutral-000); - --base: var(--neutral-750); + --basecl: var(--neutral-750); --text: var(--neutral-000); --text-alt: var(--neutral-900); --text-input: var(--text); --text-muted-input: var(--neutral-450); + --text-muted: var(--neutral-300); --muted-input: var(--neutral-500); --background-disabled: var(--neutral-500); --text-disabled: var(--neutral-700); @@ -292,11 +374,23 @@ --active-secondary: oklch(0.4471 0.15 271.61); --disabled-secondary: oklch(0.6065 0.213 271.11 / 0.4); + --destructive: oklch(0.58 0.2149 27.13); + --hover-destructive: oklch(0.58 0.2149 27.13 / 0.8); + --active-destructive: oklch(45.872% 0.16648 26.855); + --disabled-destructive: oklch(0.58 0.2149 27.13 / 0.4); + --muted: var(--color-neutral-650); --hover-muted: var(--color-neutral-500); --active-muted: var(--color-neutral-400); --disabled-muted: var(--color-neutral-400); + --toaster-default-bg: var(--color-neutral-150); + --toaster-success-bg: var(--color-green-200); + --toaster-error-bg: var(--color-red-200); + --toaster-info-bg: var(--color-blue-200); + --toaster-warning-bg: var(--color-yellow-200); + --toaster-notification-bg: var(--color-neutral-150); + --card: var(--neutral-750); /* ------------------- */ @@ -321,8 +415,6 @@ --accent-foreground: oklch(0.985 0.002 247.839); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); @@ -339,17 +431,17 @@ --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.21 0.034 264.665); + --sidebar: var(--background); - --sidebar-foreground: oklch(0.985 0.002 247.839); + --sidebar-foreground: var(--text); --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0.002 247.839); + --sidebar-primary-foreground: var(--text); --sidebar-accent: oklch(0.278 0.033 256.848); - --sidebar-accent-foreground: oklch(0.985 0.002 247.839); + --sidebar-accent-foreground: var(--text); --sidebar-border: oklch(1 0 0 / 10%); diff --git a/src/app/home/page.tsx b/src/app/home/page.tsx deleted file mode 100644 index 77f3cf8..0000000 --- a/src/app/home/page.tsx +++ /dev/null @@ -1,23 +0,0 @@ -'use client'; - -import { RedirectButton } from '@/components/buttons/redirect-button'; -import { ThemePicker } from '@/components/misc/theme-picker'; -import { useGetApiUserMe } from '@/generated/api/user/user'; - -export default function Home() { - const { data, isLoading } = useGetApiUserMe(); - - return ( - - {} - - - Hello{' '} - {isLoading ? 'Loading...' : data?.data.user?.name || 'Unknown User'} - - - - - - ); -} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 201a730..47cec2d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,7 +2,9 @@ import { ThemeProvider } from '@/components/wrappers/theme-provider'; import type { Metadata } from 'next'; import './globals.css'; -import { QueryProvider } from '@/components/query-provider'; +import { QueryProvider } from '@/components/wrappers/query-provider'; +import { Toaster } from '@/components/ui/sonner'; +import { SessionProvider } from 'next-auth/react'; export const metadata: Metadata = { title: 'MeetUp', @@ -50,14 +52,17 @@ export default function RootLayout({ - - {children} - + + + {children} + + +