From 7b5716e7f0805736de37a038a263c3bd1f7117e3 Mon Sep 17 00:00:00 2001 From: clementvtrd <84911237+clementvtrd@users.noreply.github.com> Date: Thu, 6 Jun 2024 14:34:45 +0200 Subject: [PATCH 01/13] feat: google oauth --- Taskfile.yml | 3 + app/.env.dist | 8 + app/package-lock.json | 142 ++++++++++++++++++ app/package.json | 2 + .../migrations/20240606120109/migration.sql | 62 ++++++++ app/prisma/schema.prisma | 53 ++++++- app/src/actions/auth.ts | 11 ++ app/src/{mutations => actions}/message.ts | 0 app/src/app/api/auth/[...nextauth]/route.ts | 2 + app/src/app/layout.tsx | 2 + app/src/app/messages/page.tsx | 2 +- app/src/app/page.tsx | 8 + app/src/auth.ts | 37 +++++ app/src/components/AuthButton.tsx | 14 ++ app/src/components/header.tsx | 24 +++ docker-compose.yaml | 7 + 16 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 app/prisma/migrations/20240606120109/migration.sql create mode 100644 app/src/actions/auth.ts rename app/src/{mutations => actions}/message.ts (100%) create mode 100644 app/src/app/api/auth/[...nextauth]/route.ts create mode 100644 app/src/app/page.tsx create mode 100644 app/src/auth.ts create mode 100644 app/src/components/AuthButton.tsx create mode 100644 app/src/components/header.tsx diff --git a/Taskfile.yml b/Taskfile.yml index e9e1cd6..798eb2e 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -13,6 +13,7 @@ tasks: cmds: - cmd: docker compose up --detach - task: prisma:deploy + - task: prisma:generate init-env: desc: Copy default environment variables @@ -20,6 +21,8 @@ tasks: cmd: cp .env.dist .env.local ignore_error: true dir: app + status: + - test -f .env.local docker-build: desc: Build the Docker image diff --git a/app/.env.dist b/app/.env.dist index 108b00b..af37ec9 100644 --- a/app/.env.dist +++ b/app/.env.dist @@ -1 +1,9 @@ BASE_URL=http://localhost:3000 + +# Generate a new one with `docker compose run --rm --no-deps app npx auth secret` +AUTH_SECRET= + +# Google IDs https://console.cloud.google.com/apis/credentials?hl=fr&project=knp-hot-tools-1717666401021 +AUTH_GOOGLE_ID= +AUTH_GOOGLE_SECRET= +AUTH_GOOGLE_RESTRICT_DOMAIN= diff --git a/app/package-lock.json b/app/package-lock.json index a542810..d31a37a 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -8,8 +8,10 @@ "name": "nextjs-fullstack", "version": "0.1.0", "dependencies": { + "@auth/prisma-adapter": "^2.2.0", "@prisma/client": "^5.15.0", "next": "14.2.3", + "next-auth": "^5.0.0-beta.19", "prisma-zod-generator": "^0.8.13", "react": "^18", "react-dom": "^18" @@ -53,6 +55,49 @@ "nun": "bin/nun.mjs" } }, + "node_modules/@auth/core": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.32.0.tgz", + "integrity": "sha512-3+ssTScBd+1fd0/fscAyQN1tSygXzuhysuVVzB942ggU4mdfiTbv36P0ccVnExKWYJKvu3E2r3/zxXCCAmTOrg==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.1.1", + "@types/cookie": "0.6.0", + "cookie": "0.6.0", + "jose": "^5.1.3", + "oauth4webapi": "^2.9.0", + "preact": "10.11.3", + "preact-render-to-string": "5.2.3" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^6.8.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@auth/prisma-adapter": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-2.2.0.tgz", + "integrity": "sha512-DiXgyJfqBb/6TPhNJ5IcRw3klxcFD+qaa7qvOYopV6rYinSUvsQkJ30m+so2SRlEsuDA6PqMu+Mf5Sd+zr3g+Q==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.32.0" + }, + "peerDependencies": { + "@prisma/client": ">=2.26.0 || >=3 || >=4 || >=5" + } + }, "node_modules/@babel/code-frame": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.6.tgz", @@ -544,6 +589,15 @@ "node": ">=8.0.0" } }, + "node_modules/@panva/hkdf": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.1.1.tgz", + "integrity": "sha512-dhPeilub1NuIG0X5Kvhh9lH4iW3ZsHlnzwgwbOlgwQ2wG1IqFzsgHqmKPk3WzsdWAeaxKJxgM0+W433RmN45GA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -820,6 +874,12 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "license": "MIT" + }, "node_modules/@types/cross-spawn": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.2.tgz", @@ -1854,6 +1914,15 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -4001,6 +4070,15 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.4.0.tgz", + "integrity": "sha512-6rpxTHPAQyWMb9A35BroFl1Sp0ST3DpPcm5EVIxZxdH+e0Hv9fwhyB3XLKFUcHNpdSDnETmBfuPPTTlYz5+USw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4456,6 +4534,33 @@ } } }, + "node_modules/next-auth": { + "version": "5.0.0-beta.19", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.19.tgz", + "integrity": "sha512-YHu1igcAxZPh8ZB7GIM93dqgY6gcAzq66FOhQFheAdOx1raxNcApt05nNyNCSB6NegSiyJ4XOPsaNow4pfDmsg==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.32.0" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "next": "^14 || ^15.0.0-0", + "nodemailer": "^6.6.5", + "react": "^18.2.0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -4624,6 +4729,15 @@ "node": ">=8" } }, + "node_modules/oauth4webapi": { + "version": "2.10.4", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.10.4.tgz", + "integrity": "sha512-DSoj8QoChzOCQlJkRmYxAJCIpnXFW32R0Uq7avyghIeB6iJq0XAblOD7pcq3mx4WEBDwMuKr0Y1qveCBleG2Xw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5239,6 +5353,28 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "dev": true }, + "node_modules/preact": { + "version": "10.11.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz", + "integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz", + "integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==", + "license": "MIT", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -5263,6 +5399,12 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", + "license": "MIT" + }, "node_modules/prisma": { "version": "5.15.0", "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.15.0.tgz", diff --git a/app/package.json b/app/package.json index 702eec8..6c2527b 100644 --- a/app/package.json +++ b/app/package.json @@ -11,8 +11,10 @@ "type-check": "tsc" }, "dependencies": { + "@auth/prisma-adapter": "^2.2.0", "@prisma/client": "^5.15.0", "next": "14.2.3", + "next-auth": "^5.0.0-beta.19", "prisma-zod-generator": "^0.8.13", "react": "^18", "react-dom": "^18" diff --git a/app/prisma/migrations/20240606120109/migration.sql b/app/prisma/migrations/20240606120109/migration.sql new file mode 100644 index 0000000..96ba996 --- /dev/null +++ b/app/prisma/migrations/20240606120109/migration.sql @@ -0,0 +1,62 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "name" TEXT, + "email" TEXT NOT NULL, + "emailVerified" TIMESTAMP(3), + "image" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Account" ( + "userId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "providerAccountId" TEXT NOT NULL, + "refresh_token" TEXT, + "access_token" TEXT, + "expires_at" INTEGER, + "token_type" TEXT, + "scope" TEXT, + "id_token" TEXT, + "session_state" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Account_pkey" PRIMARY KEY ("provider","providerAccountId") +); + +-- CreateTable +CREATE TABLE "Session" ( + "sessionToken" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "expires" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL +); + +-- CreateTable +CREATE TABLE "VerificationToken" ( + "identifier" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expires" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "VerificationToken_pkey" PRIMARY KEY ("identifier","token") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken"); + +-- AddForeignKey +ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/app/prisma/schema.prisma b/app/prisma/schema.prisma index 811a342..c7d3d54 100644 --- a/app/prisma/schema.prisma +++ b/app/prisma/schema.prisma @@ -1,4 +1,3 @@ -// learn more about it in the docs: https://pris.ly/d/prisma-schema generator client { provider = "prisma-client-js" } @@ -17,3 +16,55 @@ model Message { createdAt DateTime @default(now()) content String } + +model User { + id String @id @default(cuid()) + name String? + email String @unique + emailVerified DateTime? + image String? + accounts Account[] + sessions Session[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Account { + userId String + type String + provider String + providerAccountId String + refresh_token String? + access_token String? + expires_at Int? + token_type String? + scope String? + id_token String? + session_state String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@id([provider, providerAccountId]) +} + +model Session { + sessionToken String @unique + userId String + expires DateTime + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model VerificationToken { + identifier String + token String + expires DateTime + + @@id([identifier, token]) +} diff --git a/app/src/actions/auth.ts b/app/src/actions/auth.ts new file mode 100644 index 0000000..e80dfab --- /dev/null +++ b/app/src/actions/auth.ts @@ -0,0 +1,11 @@ +"use server"; + +import { signIn, signOut } from "@/auth"; + +export async function loginWithGoogle() { + await signIn("google"); +}; + +export async function logout() { + await signOut(); +} diff --git a/app/src/mutations/message.ts b/app/src/actions/message.ts similarity index 100% rename from app/src/mutations/message.ts rename to app/src/actions/message.ts diff --git a/app/src/app/api/auth/[...nextauth]/route.ts b/app/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..7c62e2d --- /dev/null +++ b/app/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,2 @@ +import { handlers } from "@/auth"; +export const { GET, POST } = handlers; diff --git a/app/src/app/layout.tsx b/app/src/app/layout.tsx index ed9772c..08e4427 100644 --- a/app/src/app/layout.tsx +++ b/app/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import "./globals.css"; import Luciole from '@/fonts/luciole'; +import Header from '@/components/header'; export const metadata: Metadata = { title: "Create Next App", @@ -15,6 +16,7 @@ export default function RootLayout({ return ( +
{children} diff --git a/app/src/app/messages/page.tsx b/app/src/app/messages/page.tsx index c811fbe..1f34cc8 100644 --- a/app/src/app/messages/page.tsx +++ b/app/src/app/messages/page.tsx @@ -1,6 +1,6 @@ "use server"; -import { createMessage } from '@/mutations/message'; +import { createMessage } from '@/actions/message'; import getAllMessages from '@/resolvers/message/getAllMessages'; import Link from 'next/link'; diff --git a/app/src/app/page.tsx b/app/src/app/page.tsx new file mode 100644 index 0000000..219c3ec --- /dev/null +++ b/app/src/app/page.tsx @@ -0,0 +1,8 @@ +export default function Home() { + return ( +
+

Home

+

Welcome to the home page.

+
+ ); +} diff --git a/app/src/auth.ts b/app/src/auth.ts new file mode 100644 index 0000000..9076803 --- /dev/null +++ b/app/src/auth.ts @@ -0,0 +1,37 @@ +import NextAuth from "next-auth"; +import google from 'next-auth/providers/google'; +import { PrismaAdapter } from "@auth/prisma-adapter"; +import client from '@/prisma-client'; + +const DOMAIN_RESCTRICT = process.env.AUTH_GOOGLE_RESTRICT_DOMAIN ?? ''; + +export const { handlers, signIn, signOut, auth } = NextAuth({ + adapter: PrismaAdapter(client), + providers: [ + google({ + authorization: { + params: { + prompt: "consent", + access_type: "offline", + response_type: "code" + } + } + }) + ], + callbacks: { + async signIn(user): Promise { + if (DOMAIN_RESCTRICT === '') { + return true; + } + + if (user.account?.provider === "google") { + return user.profile != null + && user.profile.email_verified === true + && typeof user.profile.email === 'string' + && user.profile.email.endsWith(DOMAIN_RESCTRICT); + } + + return false; + } + } +}); diff --git a/app/src/components/AuthButton.tsx b/app/src/components/AuthButton.tsx new file mode 100644 index 0000000..a9d5f0f --- /dev/null +++ b/app/src/components/AuthButton.tsx @@ -0,0 +1,14 @@ +"use client"; + +export default function AuthButton({ action, children }: { + action: () => Promise; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/app/src/components/header.tsx b/app/src/components/header.tsx new file mode 100644 index 0000000..d5cb996 --- /dev/null +++ b/app/src/components/header.tsx @@ -0,0 +1,24 @@ +"use server"; + +import { loginWithGoogle, logout } from '@/actions/auth'; +import { auth } from '@/auth'; +import AuthButton from '@/components/AuthButton'; + +export default async function Header() { + const session = await auth(); + + return ( +
+ { + session?.user == null + ? Sign In + : ( + <> +

Welcome, {session.user.name}

+ Sign Out + + ) + } +
+ ); +} diff --git a/docker-compose.yaml b/docker-compose.yaml index 61f14e8..84d241f 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -30,6 +30,13 @@ services: retries: 5 start_period: 10s + adminer: + image: adminer + restart: unless-stopped + environment: + - ADMINER_DEFAULT_SERVER=postgres + ports: + - '8080:8080' # Generate Zod schemas prisma-zod-generator: From 2fa7a67801d1e277e16cf0ddaeeaa1e9aaf4433d Mon Sep 17 00:00:00 2001 From: clementvtrd <84911237+clementvtrd@users.noreply.github.com> Date: Thu, 6 Jun 2024 15:25:15 +0200 Subject: [PATCH 02/13] feat: use google picture and icon in header --- app/next.config.mjs | 13 ++++++++++++- app/package-lock.json | 10 ++++++++++ app/package.json | 2 +- app/src/app/layout.tsx | 8 +++++--- app/src/components/Icon.tsx | 15 +++++++++++++++ app/src/components/header.tsx | 32 +++++++++++++++++++++++++++----- 6 files changed, 70 insertions(+), 10 deletions(-) create mode 100644 app/src/components/Icon.tsx diff --git a/app/next.config.mjs b/app/next.config.mjs index 68dea63..4ebe1dd 100644 --- a/app/next.config.mjs +++ b/app/next.config.mjs @@ -1,6 +1,17 @@ /** @type {import('next').NextConfig} */ const nextConfig = { - output: "standalone" + output: "standalone", + transpilePackages: [ + "lucide-react", + ], + images: { + remotePatterns: [ + { + hostname: '*.googleusercontent.com', + protocol: 'https', + } + ] + } }; export default nextConfig; diff --git a/app/package-lock.json b/app/package-lock.json index d31a37a..29d7bb7 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@auth/prisma-adapter": "^2.2.0", "@prisma/client": "^5.15.0", + "lucide-react": "^0.387.0", "next": "14.2.3", "next-auth": "^5.0.0-beta.19", "prisma-zod-generator": "^0.8.13", @@ -4340,6 +4341,15 @@ "node": "14 || >=16.14" } }, + "node_modules/lucide-react": { + "version": "0.387.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.387.0.tgz", + "integrity": "sha512-NyB4oJZ0pzLHT/QgMpgCPbez6yqvz8QPBocMJBXQCInPpXcQVCUpcU1CDlRG8mT2j0KqodLQYp+F5zn8U86sXg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", diff --git a/app/package.json b/app/package.json index 6c2527b..5e75cf2 100644 --- a/app/package.json +++ b/app/package.json @@ -2,7 +2,6 @@ "name": "nextjs-fullstack", "version": "0.1.0", "private": true, - "type": "module", "scripts": { "dev": "next dev", "build": "next build", @@ -13,6 +12,7 @@ "dependencies": { "@auth/prisma-adapter": "^2.2.0", "@prisma/client": "^5.15.0", + "lucide-react": "^0.387.0", "next": "14.2.3", "next-auth": "^5.0.0-beta.19", "prisma-zod-generator": "^0.8.13", diff --git a/app/src/app/layout.tsx b/app/src/app/layout.tsx index 08e4427..303c438 100644 --- a/app/src/app/layout.tsx +++ b/app/src/app/layout.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import "./globals.css"; import Luciole from '@/fonts/luciole'; -import Header from '@/components/header'; +import Header from '@/components/Header'; export const metadata: Metadata = { title: "Create Next App", @@ -15,9 +15,11 @@ export default function RootLayout({ }>) { return ( - +
- {children} +
+ {children} +
); diff --git a/app/src/components/Icon.tsx b/app/src/components/Icon.tsx new file mode 100644 index 0000000..df641b5 --- /dev/null +++ b/app/src/components/Icon.tsx @@ -0,0 +1,15 @@ +import dynamic from 'next/dynamic'; +import { LucideProps } from 'lucide-react'; +import dynamicIconImports from 'lucide-react/dynamicIconImports'; + +interface IconProps extends LucideProps { + name: keyof typeof dynamicIconImports; +} + +const Icon = ({ name, ...props }: IconProps) => { + const LucideIcon = dynamic(dynamicIconImports[name]); + + return ; +}; + +export default Icon; diff --git a/app/src/components/header.tsx b/app/src/components/header.tsx index d5cb996..fc38b3f 100644 --- a/app/src/components/header.tsx +++ b/app/src/components/header.tsx @@ -3,19 +3,41 @@ import { loginWithGoogle, logout } from '@/actions/auth'; import { auth } from '@/auth'; import AuthButton from '@/components/AuthButton'; +import Icon from '@/components/Icon'; +import Image from 'next/image'; export default async function Header() { const session = await auth(); return ( -
+
{ session?.user == null - ? Sign In - : ( + ? ( + + + Log in + + ) : ( <> -

Welcome, {session.user.name}

- Sign Out + { + session.user.image != null + ? ( + User profile image) : ( + + {session.user.name?.charAt(0)} + ) + } + + + Log out + ) } From cae145cd6a6be6c728b1d6609b7b928e136b7a2b Mon Sep 17 00:00:00 2001 From: clementvtrd <84911237+clementvtrd@users.noreply.github.com> Date: Thu, 6 Jun 2024 15:45:42 +0200 Subject: [PATCH 03/13] doc: updates readme --- .task/checksum/npm-install | 1 + README.md | 62 ++++++++++++++++++++++++++++++++++++++ Taskfile.yml | 12 +++++++- app/.env.dist | 5 ++- app/README.md | 36 ---------------------- app/package-lock.json | 1 + app/package.json | 3 +- docker-compose.yaml | 10 +----- 8 files changed, 80 insertions(+), 50 deletions(-) create mode 100644 .task/checksum/npm-install delete mode 100644 app/README.md diff --git a/.task/checksum/npm-install b/.task/checksum/npm-install new file mode 100644 index 0000000..02a48d0 --- /dev/null +++ b/.task/checksum/npm-install @@ -0,0 +1 @@ +3b45ef577fa625b06731f3d3d182e939 diff --git a/README.md b/README.md index b915745..ddec71b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,64 @@ [![.github/workflows/test.yaml](https://github.com/clementvtrd/nextjs-fullstack/actions/workflows/test.yaml/badge.svg?branch=main)](https://github.com/clementvtrd/nextjs-fullstack/actions/workflows/test.yaml) + # NextJS Fullstack + +## Installation + +> This project was built around Docker version 25.0 and Docker Compose plugin version 2.27. + +### Taskfile + +A `Taskfile.yml`is present at the root of the repository which containers the common commands to bootstrap the project. Subfiles stands in the directory `tasks` and are imported at the beginning of the main file. + +You can install the `task` CLI through their website at [taskfile.dev/installation](https://taskfile.dev/installation/). + +### Boostrap + +To bootstrap the project, first run the `init` command: + +```sh +task init +``` + +This will copy the default .env, build images and install NodeJS dependencies. + +You needs to update the [.env.local](./app/.env.local) with the following information: + +- `AUTH_SECRET` can be generate with this command: + + ```sh + docker compose run --rm --no-deps app npx auth secret + ``` + +- `AUTH_GOOGLE_ID` and `AUTH_GOOGLE_SECRET` are available in [Google Cloud Platform](https://console.cloud.google.com) under APIs & Services > Credentials. As of writing those lines, only two possibility remains : + + 1. use the `KNP Hot Tools (localhost)` for development purpose only ; + 2. use the `KNP Hot Tools (prod)` for production server under knpnet.net domain ; + +- `AUTH_GOOGLE_RESTRICT_DOMAIN` might be blank during development, so you can loggin with any Google account or with `@knplabs.com` to restrict Google account from KNP Labs. + +### Starting containers + +Simply run: + +```sh +task start +``` + +## Adminer + +You can start Adminer with the following command: + +```sh +docker compose --profile adminer up -d adminer +``` + +## Help + +You can si all available shortcuts defined with Taskfile with this command[^1]: + +```sh +task --list +``` + +[^1]: Only shortcuts with a description (`desc`) are displayed diff --git a/Taskfile.yml b/Taskfile.yml index 798eb2e..775d902 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -10,10 +10,13 @@ tasks: - init-env - docker-build - npm-install + + start: + desc: Start the project + deps: [init] cmds: - cmd: docker compose up --detach - task: prisma:deploy - - task: prisma:generate init-env: desc: Copy default environment variables @@ -33,3 +36,10 @@ tasks: desc: Install the dependencies deps: [docker-build] cmd: docker compose run --no-deps --rm app npm install --ci + sources: + - app/package.json + - app/package-lock.json + - app/prisma/schema.prisma + generates: + - app/node_modules/**/* + - app/prisma/generated/**/* diff --git a/app/.env.dist b/app/.env.dist index af37ec9..988617d 100644 --- a/app/.env.dist +++ b/app/.env.dist @@ -1,9 +1,8 @@ BASE_URL=http://localhost:3000 -# Generate a new one with `docker compose run --rm --no-deps app npx auth secret` -AUTH_SECRET= +## Auth JS -# Google IDs https://console.cloud.google.com/apis/credentials?hl=fr&project=knp-hot-tools-1717666401021 +AUTH_SECRET= AUTH_GOOGLE_ID= AUTH_GOOGLE_SECRET= AUTH_GOOGLE_RESTRICT_DOMAIN= diff --git a/app/README.md b/app/README.md deleted file mode 100644 index c403366..0000000 --- a/app/README.md +++ /dev/null @@ -1,36 +0,0 @@ -This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). - -## Getting Started - -First, run the development server: - -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` - -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. - -This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/app/package-lock.json b/app/package-lock.json index 29d7bb7..b1c159d 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "nextjs-fullstack", "version": "0.1.0", + "hasInstallScript": true, "dependencies": { "@auth/prisma-adapter": "^2.2.0", "@prisma/client": "^5.15.0", diff --git a/app/package.json b/app/package.json index 5e75cf2..251a438 100644 --- a/app/package.json +++ b/app/package.json @@ -7,7 +7,8 @@ "build": "next build", "start": "next start", "lint": "next lint", - "type-check": "tsc" + "type-check": "tsc", + "postinstall": "prisma generate" }, "dependencies": { "@auth/prisma-adapter": "^2.2.0", diff --git a/docker-compose.yaml b/docker-compose.yaml index 84d241f..486538b 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -33,16 +33,8 @@ services: adminer: image: adminer restart: unless-stopped + profiles: [adminer] environment: - ADMINER_DEFAULT_SERVER=postgres ports: - '8080:8080' - - # Generate Zod schemas - prisma-zod-generator: - build: - context: app - target: development - volumes: - - ./app:/opt/app - command: npx prisma generate From aa08bfba87dc27dd489f23593d43b4d8b1578039 Mon Sep 17 00:00:00 2001 From: AntoineGonzalez <45924026+AntoineGonzalez@users.noreply.github.com> Date: Thu, 6 Jun 2024 16:15:53 +0200 Subject: [PATCH 04/13] fix: workaround to edge-compatibility issue on authjs prisma adapter --- .task/checksum/npm-install | 2 +- .../migration.sql | 24 +++++-------- app/prisma/schema.prisma | 11 ------ app/src/auth.config.ts | 34 +++++++++++++++++++ app/src/auth.ts | 33 ++---------------- app/src/middleware.ts | 14 ++++++++ 6 files changed, 61 insertions(+), 57 deletions(-) rename app/prisma/migrations/{20240606120109 => 20240606161446}/migration.sql (76%) create mode 100644 app/src/auth.config.ts create mode 100644 app/src/middleware.ts diff --git a/.task/checksum/npm-install b/.task/checksum/npm-install index 02a48d0..9572991 100644 --- a/.task/checksum/npm-install +++ b/.task/checksum/npm-install @@ -1 +1 @@ -3b45ef577fa625b06731f3d3d182e939 +a37bf263bb7a27ec2c9ccb37408b9bdb diff --git a/app/prisma/migrations/20240606120109/migration.sql b/app/prisma/migrations/20240606161446/migration.sql similarity index 76% rename from app/prisma/migrations/20240606120109/migration.sql rename to app/prisma/migrations/20240606161446/migration.sql index 96ba996..55c6b07 100644 --- a/app/prisma/migrations/20240606120109/migration.sql +++ b/app/prisma/migrations/20240606161446/migration.sql @@ -1,3 +1,12 @@ +-- CreateTable +CREATE TABLE "Message" ( + "id" SERIAL NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "content" TEXT NOT NULL, + + CONSTRAINT "Message_pkey" PRIMARY KEY ("id") +); + -- CreateTable CREATE TABLE "User" ( "id" TEXT NOT NULL, @@ -30,15 +39,6 @@ CREATE TABLE "Account" ( CONSTRAINT "Account_pkey" PRIMARY KEY ("provider","providerAccountId") ); --- CreateTable -CREATE TABLE "Session" ( - "sessionToken" TEXT NOT NULL, - "userId" TEXT NOT NULL, - "expires" TIMESTAMP(3) NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL -); - -- CreateTable CREATE TABLE "VerificationToken" ( "identifier" TEXT NOT NULL, @@ -51,12 +51,6 @@ CREATE TABLE "VerificationToken" ( -- CreateIndex CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); --- CreateIndex -CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken"); - -- AddForeignKey ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; --- AddForeignKey -ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - diff --git a/app/prisma/schema.prisma b/app/prisma/schema.prisma index c7d3d54..c389e11 100644 --- a/app/prisma/schema.prisma +++ b/app/prisma/schema.prisma @@ -24,7 +24,6 @@ model User { emailVerified DateTime? image String? accounts Account[] - sessions Session[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -51,16 +50,6 @@ model Account { @@id([provider, providerAccountId]) } -model Session { - sessionToken String @unique - userId String - expires DateTime - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt -} - model VerificationToken { identifier String token String diff --git a/app/src/auth.config.ts b/app/src/auth.config.ts new file mode 100644 index 0000000..67649b8 --- /dev/null +++ b/app/src/auth.config.ts @@ -0,0 +1,34 @@ +import type { NextAuthConfig } from "next-auth"; +import google from 'next-auth/providers/google'; + +const DOMAIN_RESCTRICT = process.env.AUTH_GOOGLE_RESTRICT_DOMAIN ?? ''; + +export default { + providers: [ + google({ + authorization: { + params: { + prompt: "consent", + access_type: "offline", + response_type: "code" + } + } + }), + ], + callbacks: { + async signIn(user): Promise { + if (DOMAIN_RESCTRICT === '') { + return true; + } + + if (user.account?.provider === "google") { + return user.profile != null + && user.profile.email_verified === true + && typeof user.profile.email === 'string' + && user.profile.email.endsWith(DOMAIN_RESCTRICT); + } + + return false; + } + } +} satisfies NextAuthConfig; diff --git a/app/src/auth.ts b/app/src/auth.ts index 9076803..326b1f1 100644 --- a/app/src/auth.ts +++ b/app/src/auth.ts @@ -1,37 +1,10 @@ import NextAuth from "next-auth"; -import google from 'next-auth/providers/google'; import { PrismaAdapter } from "@auth/prisma-adapter"; import client from '@/prisma-client'; - -const DOMAIN_RESCTRICT = process.env.AUTH_GOOGLE_RESTRICT_DOMAIN ?? ''; +import authConfig from '@/auth.config'; export const { handlers, signIn, signOut, auth } = NextAuth({ adapter: PrismaAdapter(client), - providers: [ - google({ - authorization: { - params: { - prompt: "consent", - access_type: "offline", - response_type: "code" - } - } - }) - ], - callbacks: { - async signIn(user): Promise { - if (DOMAIN_RESCTRICT === '') { - return true; - } - - if (user.account?.provider === "google") { - return user.profile != null - && user.profile.email_verified === true - && typeof user.profile.email === 'string' - && user.profile.email.endsWith(DOMAIN_RESCTRICT); - } - - return false; - } - } + session: { strategy: "jwt" }, + ...authConfig, }); diff --git a/app/src/middleware.ts b/app/src/middleware.ts new file mode 100644 index 0000000..1876c92 --- /dev/null +++ b/app/src/middleware.ts @@ -0,0 +1,14 @@ +import NextAuth from "next-auth"; +import authConfig from "./auth.config"; + +export const { auth } = NextAuth(authConfig); + +export default auth((request) => { + if (request.auth === null && !request.nextUrl.pathname.endsWith('/')) { + return Response.redirect(new URL('/', request.url)); + } +}); + +export const config = { + matcher: ["/((?!api/auth|_next/static|_next/image|favicon.ico).*)"], +}; From f42dddb28c47f916e00c65de67da009634f74ad1 Mon Sep 17 00:00:00 2001 From: clementvtrd <84911237+clementvtrd@users.noreply.github.com> Date: Fri, 7 Jun 2024 12:18:48 +0200 Subject: [PATCH 05/13] feat: use unstable_cache with server action --- .task/checksum/npm-install | 2 +- .../migrations/20240606161446/migration.sql | 9 -------- app/src/actions/message.ts | 4 +++- app/src/app/api/messages/route.ts | 11 ---------- app/src/components/header.tsx | 22 ++++++++++++++----- app/src/middleware.ts | 2 +- app/src/repositories/MessageRepository.ts | 8 +++---- app/src/resolvers/message/getAllMessages.ts | 21 ++++++++---------- 8 files changed, 35 insertions(+), 44 deletions(-) delete mode 100644 app/src/app/api/messages/route.ts diff --git a/.task/checksum/npm-install b/.task/checksum/npm-install index 9572991..48f4b1b 100644 --- a/.task/checksum/npm-install +++ b/.task/checksum/npm-install @@ -1 +1 @@ -a37bf263bb7a27ec2c9ccb37408b9bdb +14ac3c3c6846f5e11a3117756346d349 diff --git a/app/prisma/migrations/20240606161446/migration.sql b/app/prisma/migrations/20240606161446/migration.sql index 55c6b07..751ae71 100644 --- a/app/prisma/migrations/20240606161446/migration.sql +++ b/app/prisma/migrations/20240606161446/migration.sql @@ -1,12 +1,3 @@ --- CreateTable -CREATE TABLE "Message" ( - "id" SERIAL NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "content" TEXT NOT NULL, - - CONSTRAINT "Message_pkey" PRIMARY KEY ("id") -); - -- CreateTable CREATE TABLE "User" ( "id" TEXT NOT NULL, diff --git a/app/src/actions/message.ts b/app/src/actions/message.ts index 8acad14..7ac5e05 100644 --- a/app/src/actions/message.ts +++ b/app/src/actions/message.ts @@ -11,7 +11,9 @@ export async function createMessage (formData: FormData) { }, }); - MessageRepository.create(data); + const message = await MessageRepository.create(data); revalidateTag('messages'); + + return message; } diff --git a/app/src/app/api/messages/route.ts b/app/src/app/api/messages/route.ts deleted file mode 100644 index c1df620..0000000 --- a/app/src/app/api/messages/route.ts +++ /dev/null @@ -1,11 +0,0 @@ -"use server"; - -import MessageRepository from '@/repositories/MessageRepository'; - -export type MessageIndexResponse = ReturnType; - -export async function GET() { - const messages = await MessageRepository.getAll(); - - return Response.json(messages); -} diff --git a/app/src/components/header.tsx b/app/src/components/header.tsx index fc38b3f..3873a0d 100644 --- a/app/src/components/header.tsx +++ b/app/src/components/header.tsx @@ -5,12 +5,21 @@ import { auth } from '@/auth'; import AuthButton from '@/components/AuthButton'; import Icon from '@/components/Icon'; import Image from 'next/image'; +import Link from 'next/link'; export default async function Header() { const session = await auth(); return ( -
+
+
+ +
+
{ session?.user == null ? ( @@ -29,18 +38,21 @@ export default async function Header() { width={32} height={32} className="rounded-full" - />) : ( + /> + ) : ( {session.user.name?.charAt(0)} - ) - } + + ) + } Log out ) - } + } +
); } diff --git a/app/src/middleware.ts b/app/src/middleware.ts index 1876c92..02ee96f 100644 --- a/app/src/middleware.ts +++ b/app/src/middleware.ts @@ -10,5 +10,5 @@ export default auth((request) => { }); export const config = { - matcher: ["/((?!api/auth|_next/static|_next/image|favicon.ico).*)"], + matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"], }; diff --git a/app/src/repositories/MessageRepository.ts b/app/src/repositories/MessageRepository.ts index 7a53517..5087951 100644 --- a/app/src/repositories/MessageRepository.ts +++ b/app/src/repositories/MessageRepository.ts @@ -2,10 +2,10 @@ import { prismaDisconnect } from '@/decorator/prismaDisconnect'; import client from '@/prisma-client'; import { Prisma } from '@prisma/client'; -class MessageRepository { +class Repository { @prismaDisconnect() public create(data: Prisma.MessageCreateInput) { - client.message.create({ data }); + return client.message.create({ data }); } @prismaDisconnect() @@ -20,6 +20,6 @@ class MessageRepository { } } -const repository = new MessageRepository(); +const MessageRepository = new Repository(); -export default repository; +export default MessageRepository; diff --git a/app/src/resolvers/message/getAllMessages.ts b/app/src/resolvers/message/getAllMessages.ts index 338428c..b72b96d 100644 --- a/app/src/resolvers/message/getAllMessages.ts +++ b/app/src/resolvers/message/getAllMessages.ts @@ -1,17 +1,14 @@ "use server"; -import { MessageIndexResponse } from '@/app/api/messages/route'; -import { handleJsonResponse } from '@/utils/request'; +import MessageRepository from '@/repositories/MessageRepository'; +import { unstable_cache } from 'next/cache'; -export default async function getAllMessages() { - const response = await fetch( - `${process.env.BASE_URL}/api/messages`, - { - next: { - tags: ['messages'], - } - } - ); +const getAll = unstable_cache( + () => MessageRepository.getAll(), + ['message', 'getAll'], + { tags: ['messages'] } +); - return handleJsonResponse(response, 'An error occurred while fetching messages. Please try again later.'); +export default async function getAllMessages() { + return getAll(); } From 4f2a39075da85bca0f490a3d40ce6e5632b74ee1 Mon Sep 17 00:00:00 2001 From: clementvtrd <84911237+clementvtrd@users.noreply.github.com> Date: Fri, 7 Jun 2024 14:14:43 +0200 Subject: [PATCH 06/13] feat: add knp logo + reset form on submit --- app/src/app/messages/page.tsx | 7 ++-- app/src/assets/logo-knp-white.svg | 61 +++++++++++++++++++++++++++++++ app/src/components/Form.tsx | 23 ++++++++++++ app/src/components/header.tsx | 16 ++++++-- app/tailwind.config.ts | 3 ++ 5 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 app/src/assets/logo-knp-white.svg create mode 100644 app/src/components/Form.tsx diff --git a/app/src/app/messages/page.tsx b/app/src/app/messages/page.tsx index 1f34cc8..7c4a965 100644 --- a/app/src/app/messages/page.tsx +++ b/app/src/app/messages/page.tsx @@ -1,6 +1,7 @@ "use server"; import { createMessage } from '@/actions/message'; +import Form from '@/components/Form'; import getAllMessages from '@/resolvers/message/getAllMessages'; import Link from 'next/link'; @@ -9,10 +10,10 @@ export default async function MessagesPage() { return (
-
+