├── .gitignore ├── .husky └── pre-commit ├── .npmrc ├── .vscode ├── launch.json └── settings.json ├── CHANGELOG.md ├── README.md ├── app ├── app.config.ts ├── app.vue ├── assets │ └── svg │ │ └── .gitkeep ├── components │ └── app │ │ ├── AppVersion.vue │ │ └── TheAppFooter.vue ├── composables │ └── app.ts ├── layouts │ ├── blank.vue │ └── default.vue ├── nuxt.config.ts ├── pages │ └── index.vue ├── public │ └── favicon.ico └── server │ ├── plugins │ └── jobs.ts │ ├── tsconfig.json │ ├── types │ └── app-info.ts │ └── utils │ ├── job-dummy.ts │ └── logger.ts ├── auth ├── components │ ├── auth-user │ │ ├── TheAuthUsers.vue │ │ ├── TheAuthUsersActions.vue │ │ ├── TheAuthUsersCreate.vue │ │ ├── TheAuthUsersFilters.vue │ │ └── TheAuthUsersTable.vue │ ├── forms │ │ ├── FormAuthLogin.vue │ │ ├── FormAuthLogout.vue │ │ ├── FormAuthUserCreate.vue │ │ ├── FormAuthUserDestroy.vue │ │ └── FormAuthUserFields.vue │ ├── helpers │ │ ├── AuthButton.vue │ │ ├── AuthUsersButton.vue │ │ └── TheAuthPage.vue │ └── inputs │ │ └── AuthRoleInput.vue ├── composables │ ├── auth-user.ts │ └── auth.ts ├── middleware │ ├── has-auth-role.ts │ ├── is-authenticated.ts │ └── not-authenticated.ts ├── nuxt.config.ts ├── pages │ └── auth │ │ ├── login.vue │ │ ├── logout.vue │ │ └── users.vue ├── plugins │ └── 0-auth.server.ts ├── server │ ├── api │ │ └── auth │ │ │ ├── login.post.ts │ │ │ └── logout.post.ts │ ├── lucia.d.ts │ ├── middleware │ │ └── auth.ts │ ├── seeds │ │ └── admin-user.ts │ ├── tsconfig.json │ ├── types │ │ └── auth-user.ts │ └── utils │ │ └── auth.ts └── utils │ └── auth-role.ts ├── graphql.config.ts ├── graphql ├── composables │ └── cursor-pagination.ts ├── nuxt.config.ts ├── plugins │ └── urql.ts ├── schema.graphql ├── server │ ├── api │ │ └── graphql.ts │ ├── tsconfig.json │ ├── types │ │ ├── prisma.ts │ │ └── scalars.ts │ └── utils │ │ ├── auth-scopes.ts │ │ ├── builder.ts │ │ ├── context.ts │ │ ├── relay.ts │ │ └── schema.ts └── utils │ ├── gql.ts │ ├── graphql.ts │ └── index.ts ├── nuxt.config.ts ├── package.json ├── pnpm-lock.yaml ├── prisma ├── migrations │ ├── 20231115160637_auth │ │ └── migration.sql │ └── migration_lock.toml ├── nuxt.config.ts ├── schema.prisma └── server │ ├── seed.ts │ ├── tsconfig.json │ └── utils │ └── prisma.ts ├── scripts ├── post-deploy.sh └── server.cjs ├── tailwind.config.ts ├── tsconfig.json ├── tsconfig.server.json ├── types └── svg.d.ts └── ui ├── assets └── css │ ├── colors.css │ ├── forms.css │ ├── main.css │ ├── tailwind.css │ ├── transitions.css │ └── typography.css ├── components ├── actions │ ├── UActionModal.vue │ └── UActionsDropdown.vue ├── helpers │ ├── UDate.vue │ ├── UFormWrapper.vue │ └── UTotalCount.vue ├── inputs │ ├── UPasswordInput.vue │ ├── USearchInput.vue │ └── USelectOptional.vue └── navigation │ ├── UCursorPagination.vue │ └── UTableSortHeader.vue ├── composables ├── dark-mode.ts └── toaster.ts └── nuxt.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .nuxt 4 | .nitro 5 | .cache 6 | dist 7 | 8 | # Node dependencies 9 | node_modules 10 | 11 | # Logs 12 | logs 13 | *.log 14 | 15 | # Misc 16 | .DS_Store 17 | .fleet 18 | .idea 19 | 20 | # Local env files 21 | .env 22 | .env.* 23 | !.env.example 24 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | ./node_modules/.bin/nano-staged 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | "version": "0.2.0", 5 | "configurations": [ 6 | { 7 | "type": "firefox", 8 | "request": "launch", 9 | "name": "Nuxt client", 10 | "url": "http://localhost:3000", 11 | "webRoot": "${workspaceFolder}", 12 | "pathMappings": [ 13 | { 14 | "url": "http://localhost:3000/_nuxt/", 15 | "path": "${workspaceFolder}/" 16 | } 17 | ] 18 | }, 19 | { 20 | "type": "node", 21 | "request": "launch", 22 | "name": "Nuxt server", 23 | "outputCapture": "std", 24 | "program": "${workspaceFolder}/node_modules/nuxi/bin/nuxi.mjs", 25 | "args": [ 26 | "dev" 27 | ] 28 | } 29 | ], 30 | "compounds": [ 31 | { 32 | "name": "Nuxt fullstack", 33 | "configurations": [ 34 | "Nuxt server", 35 | "Nuxt client" 36 | ] 37 | } 38 | ] 39 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.2.6 4 | 5 | [compare changes](https://github.com/lewebsimple/nuxt-graphql-fullstack/compare/v0.2.5...v0.2.6) 6 | 7 | ### 🚀 Enhancements 8 | 9 | - Add showCancel prop to UFormWrapper ([05a54dd](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/05a54dd)) 10 | - UDate component ([0c1e17a](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/0c1e17a)) 11 | - Backport features from real-world project ([fb50abe](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/fb50abe)) 12 | 13 | ### 🩹 Fixes 14 | 15 | - Use AuthRole from ~/graphql/utils/graphql ([4274e2e](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/4274e2e)) 16 | - Show users button if isAdministrator ([71e16d5](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/71e16d5)) 17 | 18 | ### 🏡 Chore 19 | 20 | - Update deps and set TS version ([a409bc8](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/a409bc8)) 21 | 22 | ### ❤️ Contributors 23 | 24 | - Pascal Martineau 25 | 26 | ## v0.2.5 27 | 28 | [compare changes](https://github.com/lewebsimple/nuxt-graphql-fullstack/compare/v0.2.4...v0.2.5) 29 | 30 | ### 🚀 Enhancements 31 | 32 | - USelectOptional ([6d1592c](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/6d1592c)) 33 | - TheAuthUsersTable sort ([bc3e925](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/bc3e925)) 34 | - UBulkActions ([c6c629b](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/c6c629b)) 35 | - UActionModal / UActionsDropdown ([ea08f35](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/ea08f35)) 36 | - FormEntityDestroy ([fb95d6c](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/fb95d6c)) 37 | - Add users link in app footer ([585a3d4](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/585a3d4)) 38 | 39 | ### 🩹 Fixes 40 | 41 | - Assets/svg/.gitkeep ([403baa8](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/403baa8)) 42 | - Svg type definitions ([ee8e696](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/ee8e696)) 43 | - AuthRole enum ([c015c52](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/c015c52)) 44 | - Pseudo-mobile friendliness ([0ce72df](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/0ce72df)) 45 | - FormAuthUserCreate error handling ([a4bb1d1](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/a4bb1d1)) 46 | 47 | ### 💅 Refactors 48 | 49 | - Move jobs in app layer ([38f4ff3](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/38f4ff3)) 50 | - FormAuth* ([cad791e](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/cad791e)) 51 | - TheAuthUsers CRUD ([8a9b20d](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/8a9b20d)) 52 | - TheAuthUsersActions ([8eac1bb](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/8eac1bb)) 53 | - TheAuthUsers structure ([8d75a85](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/8d75a85)) 54 | 55 | ### 🏡 Chore 56 | 57 | - Backport from real-world project ([7b3f0fd](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/7b3f0fd)) 58 | 59 | ### ❤️ Contributors 60 | 61 | - Pascal Martineau 62 | 63 | ## v0.2.4 64 | 65 | [compare changes](https://github.com/lewebsimple/nuxt-graphql-fullstack/compare/v0.2.3...v0.2.4) 66 | 67 | ### 🚀 Enhancements 68 | 69 | - Backport stuff from real-world projects ([f085f04](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/f085f04)) 70 | 71 | ### 💅 Refactors 72 | 73 | - Better project structure ([96ecaf1](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/96ecaf1)) 74 | 75 | ### ❤️ Contributors 76 | 77 | - Pascal Martineau 78 | 79 | ## v0.2.3 80 | 81 | [compare changes](https://github.com/lewebsimple/nuxt-graphql-fullstack/compare/v0.2.2...v0.2.3) 82 | 83 | ### 🚀 Enhancements 84 | 85 | - Disable login / signup forms if invalid state ([0036e8b](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/0036e8b)) 86 | 87 | ### 💅 Refactors 88 | 89 | - Ui layer with Tailwind CSS / Nuxt UI ([ea13486](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/ea13486)) 90 | 91 | ### ❤️ Contributors 92 | 93 | - Pascal Martineau 94 | 95 | ## v0.2.2 96 | 97 | [compare changes](https://github.com/lewebsimple/nuxt-graphql-fullstack/compare/v0.2.1...v0.2.2) 98 | 99 | ## v0.2.1 100 | 101 | [compare changes](https://github.com/lewebsimple/nuxt-graphql-fullstack/compare/v0.2.0...v0.2.1) 102 | 103 | ### 🚀 Enhancements 104 | 105 | - Background jobs with bullmq ([3a48b49](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/3a48b49)) 106 | 107 | ### ❤️ Contributors 108 | 109 | - Pascal Martineau 110 | 111 | ## v0.1.5 112 | 113 | 114 | ### 🚀 Enhancements 115 | 116 | - Initial Prisma schema and migration ([5e5416f](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/5e5416f)) 117 | - Auth setup with Lucia ([c3b7996](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/c3b7996)) 118 | - Initial GraphQL schema ([40d7543](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/40d7543)) 119 | - Save GraphQL schema to file from code ([f81c827](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/f81c827)) 120 | - GraphQL @urql/vue client ([9f63458](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/9f63458)) 121 | - @nuxt/ui ([c808062](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/c808062)) 122 | - More styles ([fae28c7](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/fae28c7)) 123 | - Dev script with codegen ([8fd3efa](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/8fd3efa)) 124 | 125 | ### 🩹 Fixes 126 | 127 | - Only generate graphql operations ([3c9ee56](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/3c9ee56)) 128 | - Import auth in seed ([2fd07fd](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/2fd07fd)) 129 | 130 | ### 💅 Refactors 131 | 132 | - Auth signup ([d497bf6](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/d497bf6)) 133 | - Auth layer ([45e1630](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/45e1630)) 134 | - App / graphql layers ([d9fabda](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/d9fabda)) 135 | 136 | ### 🏡 Chore 137 | 138 | - Initial Nuxt project ([f53a757](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/f53a757)) 139 | - Upate eslint / stylelint config ([16faab4](https://github.com/lewebsimple/nuxt-graphql-fullstack/commit/16faab4)) 140 | 141 | ### ❤️ Contributors 142 | 143 | - Pascal Martineau 144 | 145 | ## v0.1.4 146 | 147 | [compare changes](https://github.com/lewebsimple/nuxt-minimal/compare/v0.1.3...v0.1.4) 148 | 149 | ### 🏡 Chore 150 | 151 | - Update dependencies ([fbb715f](https://github.com/lewebsimple/nuxt-minimal/commit/fbb715f)) 152 | - Update dependencies ([38d2f52](https://github.com/lewebsimple/nuxt-minimal/commit/38d2f52)) 153 | - Update dependencies ([47bab26](https://github.com/lewebsimple/nuxt-minimal/commit/47bab26)) 154 | 155 | ### ❤️ Contributors 156 | 157 | - Pascal Martineau 158 | 159 | ## v0.1.3 160 | 161 | [compare changes](https://github.com/lewebsimple/nuxt-minimal/compare/v0.1.2...v0.1.3) 162 | 163 | ### 🏡 Chore 164 | 165 | - Update dependencies ([f94ddbf](https://github.com/lewebsimple/nuxt-minimal/commit/f94ddbf)) 166 | - Update dependencies ([1c2cb15](https://github.com/lewebsimple/nuxt-minimal/commit/1c2cb15)) 167 | 168 | ### ❤️ Contributors 169 | 170 | - Pascal Martineau 171 | 172 | ## v0.1.2 173 | 174 | [compare changes](https://github.com/lewebsimple/nuxt-minimal/compare/v0.1.1...v0.1.2) 175 | 176 | ### 🚀 Enhancements 177 | 178 | - Initial layout / home page ([61a0703](https://github.com/lewebsimple/nuxt-minimal/commit/61a0703)) 179 | 180 | ### 🩹 Fixes 181 | 182 | - Repository details in package.json ([8e18de0](https://github.com/lewebsimple/nuxt-minimal/commit/8e18de0)) 183 | 184 | ### 🏡 Chore 185 | 186 | - Update dependencies ([7e71c9a](https://github.com/lewebsimple/nuxt-minimal/commit/7e71c9a)) 187 | - Update deps ([fe20740](https://github.com/lewebsimple/nuxt-minimal/commit/fe20740)) 188 | 189 | ### ❤️ Contributors 190 | 191 | - Pascal Martineau 192 | 193 | ## v0.1.1 194 | 195 | 196 | ### 🚀 Enhancements 197 | 198 | - Automatic code linting and changelog generation (8f010cf) 199 | 200 | ### 🏡 Chore 201 | 202 | - Initial Nuxt project (0af05eb) 203 | 204 | ### ❤️ Contributors 205 | 206 | - Pascal Martineau 207 | 208 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nuxt 3 GraphQL fullstack boilerplate 2 | 3 | Look at the [Nuxt 3 documentation](https://nuxt.com/docs/getting-started/introduction) to learn more. 4 | 5 | ## Setup 6 | 7 | Make sure to install the dependencies: 8 | 9 | ```bash 10 | # npm 11 | npm install 12 | 13 | # pnpm 14 | pnpm install 15 | 16 | # yarn 17 | yarn install 18 | ``` 19 | 20 | ## Development Server 21 | 22 | Start the development server on `http://localhost:3000`: 23 | 24 | ```bash 25 | # npm 26 | npm run dev 27 | 28 | # pnpm 29 | pnpm run dev 30 | 31 | # yarn 32 | yarn dev 33 | ``` 34 | 35 | ## Production 36 | 37 | Build the application for production: 38 | 39 | ```bash 40 | # npm 41 | npm run build 42 | 43 | # pnpm 44 | pnpm run build 45 | 46 | # yarn 47 | yarn build 48 | ``` 49 | 50 | Locally preview production build: 51 | 52 | ```bash 53 | # npm 54 | npm run preview 55 | 56 | # pnpm 57 | pnpm run preview 58 | 59 | # yarn 60 | yarn preview 61 | ``` 62 | 63 | Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information. 64 | -------------------------------------------------------------------------------- /app/app.config.ts: -------------------------------------------------------------------------------- 1 | export default defineAppConfig({ 2 | nuxtIcon: {}, 3 | ui: { 4 | // Colors 5 | primary: "indigo", 6 | gray: "neutral", 7 | 8 | // Table 9 | table: { 10 | default: { 11 | // @see https://github.com/nuxt/ui/commit/f07968afef263d38183ce6c9cd9185ef7eee0494 12 | // loadingState: { label: "Chargement..." }, 13 | // emptyState: { label: "Aucun élément" }, 14 | }, 15 | th: { 16 | base: "whitespace-nowrap", 17 | }, 18 | td: { 19 | color: "text-black dark:text-white", 20 | size: "text-lg", 21 | }, 22 | }, 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /app/app.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 12 | -------------------------------------------------------------------------------- /app/assets/svg/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lewebsimple/nuxt-graphql-fullstack/abce235fa29025271e9f381bf11aef85cda35e74/app/assets/svg/.gitkeep -------------------------------------------------------------------------------- /app/components/app/AppVersion.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | -------------------------------------------------------------------------------- /app/components/app/TheAppFooter.vue: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /app/composables/app.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQuery } from "@urql/vue"; 2 | 3 | export async function useAppVersion() { 4 | const { data, error } = await useQuery({ 5 | query: graphql(` 6 | query Version { 7 | version 8 | } 9 | `), 10 | }); 11 | if (error.value) { 12 | throw createError({ statusCode: 500, message: error.value.message }); 13 | } 14 | return { version: data.value?.version }; 15 | } 16 | 17 | export function useAppMutations() { 18 | // Ping 19 | const { executeMutation: ping } = useMutation( 20 | graphql(` 21 | mutation Ping { 22 | ping 23 | } 24 | `), 25 | ); 26 | 27 | return { ping }; 28 | } 29 | -------------------------------------------------------------------------------- /app/layouts/blank.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /app/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /app/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import { description } from "../package.json"; 2 | 3 | export default defineNuxtConfig({ 4 | components: [ 5 | { path: "~/app/components", pathPrefix: false }, 6 | { path: "~/app/assets/svg", extensions: ["svg"], prefix: "Svg" }, 7 | ], 8 | modules: ["@lewebsimple/nuxt3-svg"], 9 | runtimeConfig: { 10 | connection: { 11 | host: process.env.NUXT_REDIS_HOST || "localhost", 12 | port: parseInt(process.env.NUXT_REDIS_PORT || "6379"), 13 | }, 14 | public: { 15 | siteName: process.env.NUXT_PUBLIC_SITE_NAME || description, 16 | }, 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /app/pages/index.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | -------------------------------------------------------------------------------- /app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lewebsimple/nuxt-graphql-fullstack/abce235fa29025271e9f381bf11aef85cda35e74/app/public/favicon.ico -------------------------------------------------------------------------------- /app/server/plugins/jobs.ts: -------------------------------------------------------------------------------- 1 | import { type Queue, type Worker } from "bullmq"; 2 | 3 | const queues: Queue[] = []; 4 | const workers: Worker[] = []; 5 | 6 | export default defineNitroPlugin(async (nitroApp) => { 7 | if (queues.length) { 8 | // Clear all queues 9 | await Promise.all(queues.map((queue) => queue.obliterate({ force: true }))); 10 | logger.success(`${queues.length} job queue(s) cleared`); 11 | } 12 | 13 | if (workers.length) { 14 | // Start all workers 15 | Function.prototype(workers); 16 | logger.success(`${workers.length} job worker(s) started`); 17 | 18 | // Close all workers on close 19 | nitroApp.hooks.hookOnce("close", async () => { 20 | logger.info("Waiting for job workers to close..."); 21 | await Promise.all(workers.map((worker) => worker.close())); 22 | logger.success(`${workers.length} job workers closed`); 23 | }); 24 | } 25 | }); 26 | -------------------------------------------------------------------------------- /app/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.server.json" 3 | } -------------------------------------------------------------------------------- /app/server/types/app-info.ts: -------------------------------------------------------------------------------- 1 | import { version } from "~/package.json"; 2 | 3 | export const AppInfoQueries = builder.queryFields((t) => ({ 4 | // Application version 5 | version: t.string({ 6 | description: "Current application version", 7 | resolve: () => `v${version}`, 8 | skipTypeScopes: true, 9 | }), 10 | })); 11 | 12 | export const AppInfoMutations = builder.mutationFields((t) => ({ 13 | // Dummy ping mutation 14 | ping: t.string({ 15 | resolve: () => "Pong", 16 | skipTypeScopes: true, 17 | }), 18 | })); 19 | -------------------------------------------------------------------------------- /app/server/utils/job-dummy.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "@nuxt/kit"; 2 | import { type Job, Queue, QueueEvents, Worker } from "bullmq"; 3 | import { z } from "zod"; 4 | 5 | const { connection } = useRuntimeConfig(); 6 | 7 | // Dummy job data / return types 8 | const dummyJobDataSchema = z.object({}); 9 | export type DummyJobData = z.infer; 10 | export type DummyJobReturn = { message: string }; 11 | 12 | export const dummyQueue = new Queue("DummyJob", { connection }); 13 | export const dummyQueueEvents = new QueueEvents("DummyJob", { connection }); 14 | 15 | export const dummyWorker = new Worker( 16 | "DummyJob", 17 | async (job: Job) => { 18 | dummyJobDataSchema.parse(job.data); 19 | return { message: `DummyJob ${job.name} executed successfully` }; 20 | }, 21 | { connection }, 22 | ) 23 | .on("failed", async (job, error) => { 24 | logger.error(`DummyJob ${job?.name} failed with error: ${error.message}`); 25 | }) 26 | .on("completed", async (job) => { 27 | logger.success(job.returnvalue.message); 28 | }); 29 | -------------------------------------------------------------------------------- /app/server/utils/logger.ts: -------------------------------------------------------------------------------- 1 | export { logger } from "@nuxt/kit"; 2 | -------------------------------------------------------------------------------- /auth/components/auth-user/TheAuthUsers.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 29 | -------------------------------------------------------------------------------- /auth/components/auth-user/TheAuthUsersActions.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 27 | -------------------------------------------------------------------------------- /auth/components/auth-user/TheAuthUsersCreate.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 16 | -------------------------------------------------------------------------------- /auth/components/auth-user/TheAuthUsersFilters.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 19 | -------------------------------------------------------------------------------- /auth/components/auth-user/TheAuthUsersTable.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 38 | -------------------------------------------------------------------------------- /auth/components/forms/FormAuthLogin.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 33 | -------------------------------------------------------------------------------- /auth/components/forms/FormAuthLogout.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 20 | -------------------------------------------------------------------------------- /auth/components/forms/FormAuthUserCreate.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 33 | -------------------------------------------------------------------------------- /auth/components/forms/FormAuthUserDestroy.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 31 | -------------------------------------------------------------------------------- /auth/components/forms/FormAuthUserFields.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 18 | -------------------------------------------------------------------------------- /auth/components/helpers/AuthButton.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 12 | -------------------------------------------------------------------------------- /auth/components/helpers/AuthUsersButton.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | -------------------------------------------------------------------------------- /auth/components/helpers/TheAuthPage.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | -------------------------------------------------------------------------------- /auth/components/inputs/AuthRoleInput.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 13 | -------------------------------------------------------------------------------- /auth/composables/auth-user.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQuery } from "@urql/vue"; 2 | import { z } from "zod"; 3 | 4 | const theAuthUserFragment = graphql(` 5 | fragment TheAuthUser on AuthUser { 6 | id 7 | email 8 | role 9 | } 10 | `); 11 | 12 | export async function useAuthUsers() { 13 | const filters = ref({ search: null, role: null }); 14 | const sort = ref({ by: AuthUserSortBy.Email, order: SortOrder.Asc }); 15 | const pageInfo = computed(() => data.value?.authUserFindMany.pageInfo); 16 | const { cursorPagination, firstPage, previousPage, nextPage } = useCursorPagination(pageInfo); 17 | watch([filters, sort], (newInput, oldInput) => JSON.stringify(newInput) !== JSON.stringify(oldInput) && firstPage()); 18 | const { data, fetching, executeQuery } = await useQuery({ 19 | query: graphql(` 20 | query TheAuthUsers($filters: AuthUserFiltersMany!, $sort: AuthUserSort!, $after: String, $before: String, $first: Int, $last: Int) { 21 | authUserFindMany(filters: $filters, sort: $sort, after: $after, before: $before, first: $first, last: $last) { 22 | edges { 23 | node { 24 | ...TheAuthUser 25 | } 26 | } 27 | totalCount 28 | pageInfo { 29 | ...PageInfo 30 | } 31 | } 32 | } 33 | `), 34 | variables: computed(() => ({ filters: filters.value, sort: sort.value, ...cursorPagination.value })), 35 | }); 36 | return { 37 | filters, 38 | sort, 39 | pageInfo, 40 | firstPage, 41 | previousPage, 42 | nextPage, 43 | totalCount: computed(() => data.value?.authUserFindMany.totalCount || 0), 44 | fetching, 45 | refetch: () => executeQuery({ requestPolicy: "network-only" }), 46 | authUsers: computed( 47 | () => data.value?.authUserFindMany.edges?.map((edge) => edge?.node).filter((authUser): authUser is TheAuthUserFragment => !!authUser) || [], 48 | ), 49 | }; 50 | } 51 | 52 | // AuthUser fields schema 53 | export const authUserFieldsSchema = z.object({ 54 | email: z.string().email("Courriel invalide"), 55 | password: z.string().min(8, "Le mot de passe doit contenir au moins 8 caractères"), 56 | role: z.nativeEnum(AuthRole), 57 | }); 58 | export type AuthUserFields = z.infer; 59 | 60 | export function useAuthUserMutations() { 61 | // Create AuthUser 62 | const { executeMutation: authUserCreate } = useMutation( 63 | graphql(` 64 | mutation AuthUserCreate($data: AuthUserCreateInput!) { 65 | authUserCreate(data: $data) { 66 | ...TheAuthUser 67 | } 68 | } 69 | `), 70 | ); 71 | 72 | // Destroy many AuthUsers 73 | const { executeMutation: authUserDestroyMany } = useMutation( 74 | graphql(` 75 | mutation AuthUserDestroyMany($authUserIds: [String!]!) { 76 | authUserDestroyMany(authUserIds: $authUserIds) 77 | } 78 | `), 79 | ); 80 | return { authUserCreate, authUserDestroyMany }; 81 | } 82 | -------------------------------------------------------------------------------- /auth/composables/auth.ts: -------------------------------------------------------------------------------- 1 | import { type Session } from "lucia"; 2 | import { z } from "zod"; 3 | 4 | import { AuthRole } from "~/graphql/utils/graphql"; 5 | 6 | // Authentication login schema 7 | export const authLoginSchema = z.object({ 8 | email: z.string().email("Courriel invalide"), 9 | password: z.string().min(8, "Le mot de passe doit contenir au moins 8 caractères"), 10 | }); 11 | export type AuthLogin = z.infer; 12 | 13 | export function useAuth() { 14 | // Session state initialized from server plugin / middleware 15 | const session = useState("session", () => null); 16 | 17 | // Authentication helpers 18 | const isAuthenticated = computed(() => !!session.value?.user); 19 | const isAdministrator = computed(() => hasAuthRole(AuthRole.Administrator)); 20 | const hasAuthRole = (role: AuthRole) => session.value?.user && [AuthRole.Administrator, role].includes(session.value.user.role); 21 | 22 | // Login handler 23 | async function login(body: AuthLogin) { 24 | const { data, error } = await useFetch<{ session: Session | null }>("/api/auth/login", { 25 | method: "POST", 26 | body: { ...body }, 27 | transform: (data) => ({ 28 | ...data, 29 | session: (data.session 30 | ? { 31 | ...data.session, 32 | activePeriodExpiresAt: new Date(data.session.activePeriodExpiresAt), 33 | idlePeriodExpiresAt: new Date(data.session.idlePeriodExpiresAt), 34 | } 35 | : null) as Session | null, 36 | }), 37 | onResponseError: (context) => { 38 | throw new Error(context.response._data?.message); 39 | }, 40 | }); 41 | if (error.value) throw new Error(error.value.message); 42 | session.value = data.value?.session || null; 43 | } 44 | 45 | // Logout handler 46 | async function logout() { 47 | const { data, error } = await useFetch<{ session: Session | null }>("/api/auth/logout", { 48 | method: "POST", 49 | onResponseError: (context) => { 50 | throw new Error(context.response._data?.message); 51 | }, 52 | }); 53 | if (error.value) throw new Error(error.value.message); 54 | session.value = data.value?.session || null; 55 | } 56 | 57 | return { session, isAuthenticated, isAdministrator, hasAuthRole, login, logout }; 58 | } 59 | -------------------------------------------------------------------------------- /auth/middleware/has-auth-role.ts: -------------------------------------------------------------------------------- 1 | import { AuthRole } from "~/graphql/utils/graphql"; 2 | 3 | export default defineNuxtRouteMiddleware((to) => { 4 | const { isAuthenticated, hasAuthRole } = useAuth(); 5 | if (!isAuthenticated.value) { 6 | return navigateTo(`/auth/login?redirect=${to.fullPath}`); 7 | } else if (!hasAuthRole(to.meta.hasAuthRole || AuthRole.Administrator)) { 8 | abortNavigation({ statusCode: 403, statusMessage: "Opération non permise" }); 9 | } 10 | }); 11 | 12 | declare module "#app" { 13 | interface PageMeta { 14 | hasAuthRole?: AuthRole; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /auth/middleware/is-authenticated.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware((to) => { 2 | const { isAuthenticated } = useAuth(); 3 | if (!isAuthenticated.value) { 4 | return navigateTo(`/auth/login?redirect=${to.fullPath}`); 5 | } 6 | }); 7 | -------------------------------------------------------------------------------- /auth/middleware/not-authenticated.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware(() => { 2 | const { isAuthenticated } = useAuth(); 3 | if (isAuthenticated.value) { 4 | return navigateTo(`/auth/logout`); 5 | } 6 | }); 7 | -------------------------------------------------------------------------------- /auth/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtConfig({ 2 | components: [{ path: "~/auth/components", pathPrefix: false }], 3 | }); 4 | -------------------------------------------------------------------------------- /auth/pages/auth/login.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 14 | -------------------------------------------------------------------------------- /auth/pages/auth/logout.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 14 | -------------------------------------------------------------------------------- /auth/pages/auth/users.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 9 | -------------------------------------------------------------------------------- /auth/plugins/0-auth.server.ts: -------------------------------------------------------------------------------- 1 | import { type Session } from "lucia"; 2 | 3 | export default defineNuxtPlugin(() => { 4 | useState("session", () => useRequestEvent().context.session); 5 | }); 6 | -------------------------------------------------------------------------------- /auth/server/api/auth/login.post.ts: -------------------------------------------------------------------------------- 1 | import { type AuthLogin, authLoginSchema } from "~/auth/composables/auth"; 2 | 3 | export default defineEventHandler(async (event) => { 4 | const { email, password } = authLoginSchema.parse(await readBody(event)); 5 | const authRequest = auth.handleRequest(event); 6 | const key = await auth.useKey("email", email.toLowerCase(), password); 7 | const session = await auth.createSession({ userId: key.userId, attributes: { created_at: new Date() } }); 8 | authRequest.setSession(session); 9 | return { session }; 10 | }); 11 | -------------------------------------------------------------------------------- /auth/server/api/auth/logout.post.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async (event) => { 2 | const authRequest = auth.handleRequest(event); 3 | const session = await authRequest.validate(); 4 | if (session) { 5 | await auth.invalidateSession(session.sessionId); 6 | authRequest.setSession(null); 7 | } 8 | return { session: null }; 9 | }); 10 | -------------------------------------------------------------------------------- /auth/server/lucia.d.ts: -------------------------------------------------------------------------------- 1 | // server/lucia.d.ts 2 | /// 3 | 4 | declare namespace Lucia { 5 | type Auth = import("./utils/auth.js").Auth; 6 | type DatabaseUserAttributes = { 7 | email: string; 8 | role: import("~/graphql/utils/graphql").AuthRole; 9 | }; 10 | type DatabaseSessionAttributes = { 11 | created_at: Date; 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /auth/server/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | import { type Session } from "lucia"; 2 | 3 | export default defineEventHandler(async (event) => { 4 | const authRequest = auth.handleRequest(event); 5 | event.context.session = await authRequest.validate(); 6 | }); 7 | 8 | declare module "h3" { 9 | interface H3EventContext { 10 | session: Session | null; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /auth/server/seeds/admin-user.ts: -------------------------------------------------------------------------------- 1 | import { auth } from "~/auth/server/utils/auth"; 2 | import { AuthRole } from "~/graphql/utils/graphql"; 3 | import type { SeedFn } from "~/prisma/server/seed"; 4 | 5 | export const seedAdminUser: SeedFn = async (prisma) => { 6 | const adminUserData = { 7 | email: process.env.SEED_ADMIN_EMAIL || "admin@example.com", 8 | password: process.env.SEED_ADMIN_PASSWORD || "changeme", 9 | role: AuthRole.Administrator, 10 | }; 11 | const existing = await prisma.authUser.findUnique({ 12 | where: { email: adminUserData.email }, 13 | }); 14 | if (existing) { 15 | await auth.updateUserAttributes(existing.id, { role: adminUserData.role }); 16 | } else { 17 | await auth.createUser({ 18 | key: { 19 | providerId: "email", 20 | providerUserId: adminUserData.email, 21 | password: adminUserData.password, 22 | }, 23 | attributes: { email: adminUserData.email, role: adminUserData.role }, 24 | }); 25 | } 26 | return adminUserData.email; 27 | }; 28 | -------------------------------------------------------------------------------- /auth/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.server.json" 3 | } -------------------------------------------------------------------------------- /auth/server/types/auth-user.ts: -------------------------------------------------------------------------------- 1 | import { AuthRole, type Prisma } from "@prisma/client"; 2 | 3 | import { SortOrderEnum } from "~/graphql/server/types/prisma"; 4 | import { type AuthUserFiltersMany, type AuthUserSort, AuthUserSortBy } from "~/graphql/utils/graphql"; 5 | 6 | export const AuthRoleEnumType = builder.enumType(AuthRole, { name: "AuthRole" }); 7 | 8 | export const AuthUserPrismaNode = builder.prismaNode("AuthUser", { 9 | id: { field: "id" }, 10 | fields: (t) => ({ 11 | id: t.exposeID("id"), 12 | email: t.exposeString("email"), 13 | role: t.expose("role", { type: AuthRoleEnumType }), 14 | }), 15 | }); 16 | 17 | export const AuthUserWhereUniqueInput = builder.prismaWhereUnique("AuthUser", { 18 | name: "AuthUserWhereUnique", 19 | fields: (t) => ({ 20 | email: t.field({ type: "String", required: true }), 21 | }), 22 | }); 23 | 24 | export const AuthUserFiltersManyInput = builder.inputType("AuthUserFiltersMany", { 25 | fields: (t) => ({ 26 | search: t.field({ type: "String", required: false }), 27 | role: t.field({ type: AuthRoleEnumType, required: false }), 28 | }), 29 | }); 30 | 31 | export function authUserFiltersManyWhere(filters: AuthUserFiltersMany | undefined) { 32 | const where: Prisma.AuthUserWhereInput = {}; 33 | if (filters?.search) { 34 | where.email = { contains: filters.search }; 35 | } 36 | if (filters?.role) { 37 | where.role = filters.role; 38 | } 39 | return where; 40 | } 41 | 42 | export const AuthUserSortByEnum = builder.enumType("AuthUserSortBy", { 43 | values: ["email", "role"], 44 | }); 45 | 46 | export const AuthUserSortInput = builder.inputType("AuthUserSort", { 47 | fields: (t) => ({ 48 | by: t.field({ type: AuthUserSortByEnum, required: true }), 49 | order: t.field({ type: SortOrderEnum, required: true }), 50 | }), 51 | }); 52 | 53 | export function auhtUserSortOrderBy(sort: AuthUserSort | undefined) { 54 | const orderBy: Prisma.AuthUserOrderByWithRelationInput = {}; 55 | switch (sort?.by) { 56 | case AuthUserSortBy.Email: 57 | orderBy.email = sort.order; 58 | break; 59 | case AuthUserSortBy.Role: 60 | orderBy.role = sort.order; 61 | break; 62 | } 63 | return orderBy; 64 | } 65 | 66 | export const AuthUserQueries = builder.queryFields((t) => ({ 67 | // Find unique AuthUser 68 | authUserFindUnique: t.prismaField({ 69 | type: AuthUserPrismaNode, 70 | nullable: true, 71 | args: { where: t.arg({ type: AuthUserWhereUniqueInput, required: true }) }, 72 | resolve: async (query, _root, { where }, { prisma }) => await prisma.authUser.findUnique({ ...query, where }), 73 | }), 74 | // Find many AuthUsers 75 | authUserFindMany: t.prismaConnection({ 76 | type: AuthUserPrismaNode, 77 | cursor: "id", 78 | args: { 79 | filters: t.arg({ type: AuthUserFiltersManyInput, required: true }), 80 | sort: t.arg({ type: AuthUserSortInput, required: true }), 81 | }, 82 | totalCount: async (_root, { filters }, { prisma }) => await prisma.authUser.count({ where: authUserFiltersManyWhere(filters) }), 83 | resolve: async (query, _root, { filters, sort }, { prisma }) => { 84 | return await prisma.authUser.findMany({ 85 | ...query, 86 | where: authUserFiltersManyWhere(filters), 87 | orderBy: auhtUserSortOrderBy(sort), 88 | }); 89 | }, 90 | }), 91 | })); 92 | 93 | export const AuthUserCreateInput = builder.inputType("AuthUserCreateInput", { 94 | fields: (t) => ({ 95 | email: t.field({ type: "String", required: true }), 96 | password: t.field({ type: "String", required: true }), 97 | role: t.field({ type: AuthRoleEnumType, required: true }), 98 | }), 99 | }); 100 | 101 | export const AuthUserMutations = builder.mutationFields((t) => ({ 102 | // Create AuthUser 103 | authUserCreate: t.prismaField({ 104 | type: "AuthUser", 105 | nullable: true, 106 | args: { data: t.arg({ type: AuthUserCreateInput, required: true }) }, 107 | resolve: async (query, _root, { data }, { prisma }) => { 108 | const { email, password, role } = data; 109 | const user = await auth.createUser({ 110 | key: { providerId: "email", providerUserId: email.toLowerCase(), password }, 111 | attributes: { email: email.toLocaleLowerCase(), role } as unknown as Lucia.DatabaseUserAttributes, 112 | }); 113 | return await prisma.authUser.findUnique({ ...query, where: { id: user.userId } }); 114 | }, 115 | }), 116 | // Destroy many AuthUsers 117 | authUserDestroyMany: t.field({ 118 | type: "Int", 119 | nullable: true, 120 | args: { authUserIds: t.arg.stringList({ required: true }) }, 121 | resolve: async (_root, { authUserIds }) => { 122 | const { count } = await prisma.authUser.deleteMany({ where: { id: { in: authUserIds } } }); 123 | return count; 124 | }, 125 | }), 126 | })); 127 | -------------------------------------------------------------------------------- /auth/server/utils/auth.ts: -------------------------------------------------------------------------------- 1 | import { prisma as prismaAdapter } from "@lucia-auth/adapter-prisma"; 2 | import { lucia } from "lucia"; 3 | import { h3 } from "lucia/middleware"; 4 | 5 | import { prisma } from "~/prisma/server/utils/prisma"; 6 | 7 | export const auth = lucia({ 8 | adapter: prismaAdapter(prisma, { 9 | user: "authUser", 10 | key: "authKey", 11 | session: "authSession", 12 | }), 13 | env: process.dev ? "DEV" : "PROD", 14 | middleware: h3(), 15 | getUserAttributes: (data) => ({ 16 | email: data.email, 17 | role: data.role, 18 | }), 19 | getSessionAttributes: (data) => ({ 20 | created_at: data.created_at, 21 | }), 22 | }); 23 | 24 | export type Auth = typeof auth; 25 | -------------------------------------------------------------------------------- /auth/utils/auth-role.ts: -------------------------------------------------------------------------------- 1 | import { AuthRole } from "~/graphql/utils/graphql"; 2 | 3 | export function authRoleLabel(role: AuthRole) { 4 | switch (role) { 5 | case AuthRole.Unverified: 6 | return "Non vérifié"; 7 | case AuthRole.Verified: 8 | return "Utilisateur"; 9 | case AuthRole.Administrator: 10 | return "Administrateur"; 11 | } 12 | } 13 | 14 | export function authRoleOptions(allowEmpty = false) { 15 | return [...(allowEmpty ? [{ value: "", label: "Tous les rôles" }] : []), ...Object.values(AuthRole).map((role) => ({ value: role, label: authRoleLabel(role) }))]; 16 | } 17 | -------------------------------------------------------------------------------- /graphql.config.ts: -------------------------------------------------------------------------------- 1 | import type { IGraphQLConfig } from "graphql-config"; 2 | 3 | export default { 4 | projects: { 5 | default: { 6 | schema: "graphql/schema.graphql", 7 | documents: ["{app,auth,graphql}/composables/*.ts"], 8 | extensions: { 9 | codegen: { 10 | generates: { 11 | "graphql/utils/": { 12 | preset: "client", 13 | config: { 14 | scalars: { 15 | DateTime: "string", 16 | Upload: "File", 17 | }, 18 | useTypeImports: true, 19 | }, 20 | presetConfig: { fragmentMasking: false }, 21 | }, 22 | }, 23 | }, 24 | }, 25 | }, 26 | }, 27 | } satisfies IGraphQLConfig; 28 | -------------------------------------------------------------------------------- /graphql/composables/cursor-pagination.ts: -------------------------------------------------------------------------------- 1 | export const pageInfoFragment = graphql(` 2 | fragment PageInfo on PageInfo { 3 | hasNextPage 4 | hasPreviousPage 5 | startCursor 6 | endCursor 7 | } 8 | `); 9 | 10 | export type CursorPagination = { 11 | after: string | null; 12 | before: string | null; 13 | first: number | null; 14 | last: number | null; 15 | }; 16 | 17 | export function useCursorPagination(pageInfo: Ref, perPage = 10) { 18 | const cursorPagination = ref({ after: null, before: null, first: perPage, last: null }); 19 | 20 | function firstPage() { 21 | Object.assign(cursorPagination.value, { after: null, before: null, first: perPage, last: null }); 22 | } 23 | 24 | function previousPage() { 25 | if (!pageInfo.value?.hasPreviousPage) return; 26 | Object.assign(cursorPagination.value, { after: null, before: pageInfo.value.startCursor, first: null, last: perPage }); 27 | } 28 | 29 | function nextPage() { 30 | if (!pageInfo.value?.hasNextPage) return; 31 | Object.assign(cursorPagination.value, { after: pageInfo.value.endCursor, before: null, first: perPage, last: null }); 32 | } 33 | 34 | return { cursorPagination, firstPage, previousPage, nextPage }; 35 | } 36 | -------------------------------------------------------------------------------- /graphql/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import codegen from "vite-plugin-graphql-codegen"; 2 | 3 | export default defineNuxtConfig({ 4 | build: { transpile: ["@urql/vue"] }, 5 | vite: { plugins: [codegen()] }, 6 | }); 7 | -------------------------------------------------------------------------------- /graphql/plugins/urql.ts: -------------------------------------------------------------------------------- 1 | import urql, { cacheExchange, fetchExchange, type SSRData, ssrExchange } from "@urql/vue"; 2 | 3 | import { useState } from "#app"; 4 | 5 | export default defineNuxtPlugin((nuxtApp) => { 6 | const { origin } = useRequestURL(); 7 | const ssr = ssrExchange({ isClient: process.client }); 8 | const urqlState = useState("urql"); 9 | 10 | // Extract SSR payload on the server 11 | if (process.server) { 12 | nuxtApp.hook("app:rendered", () => { 13 | urqlState.value = ssr.extractData(); 14 | }); 15 | } 16 | 17 | // Restore SSR payload on the client 18 | if (process.client) { 19 | nuxtApp.hook("app:created", () => { 20 | ssr.restoreData(urqlState.value); 21 | }); 22 | } 23 | 24 | // Custom exchanges 25 | const exchanges = [cacheExchange, ssr, fetchExchange]; 26 | 27 | // Provide urql client 28 | const headers = { ...useRequestHeaders(), origin: useRequestURL().origin }; 29 | nuxtApp.vueApp.use(urql, { 30 | url: `${origin}/api/graphql`, 31 | exchanges, 32 | fetchOptions: { headers }, 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /graphql/schema.graphql: -------------------------------------------------------------------------------- 1 | enum AuthRole { 2 | ADMINISTRATOR 3 | UNVERIFIED 4 | VERIFIED 5 | } 6 | 7 | type AuthUser implements Node { 8 | email: String! 9 | globalId: ID! 10 | id: ID! 11 | role: AuthRole! 12 | } 13 | 14 | input AuthUserCreateInput { 15 | email: String! 16 | password: String! 17 | role: AuthRole! 18 | } 19 | 20 | input AuthUserFiltersMany { 21 | role: AuthRole 22 | search: String 23 | } 24 | 25 | input AuthUserSort { 26 | by: AuthUserSortBy! 27 | order: SortOrder! 28 | } 29 | 30 | enum AuthUserSortBy { 31 | email 32 | role 33 | } 34 | 35 | input AuthUserWhereUnique { 36 | email: String! 37 | } 38 | 39 | """ 40 | A date-time string at UTC, such as 2007-12-03T10:15:30Z, compliant with the `date-time` format outlined in section 5.6 of the RFC 3339 profile of the ISO 8601 standard for representation of dates and times using the Gregorian calendar. 41 | """ 42 | scalar DateTime 43 | 44 | type Mutation { 45 | authUserCreate(data: AuthUserCreateInput!): AuthUser 46 | authUserDestroyMany(authUserIds: [String!]!): Int 47 | ping: String! 48 | } 49 | 50 | interface Node { 51 | globalId: ID! 52 | } 53 | 54 | type PageInfo { 55 | endCursor: String 56 | hasNextPage: Boolean! 57 | hasPreviousPage: Boolean! 58 | startCursor: String 59 | } 60 | 61 | type Query { 62 | authUserFindMany(after: String, before: String, filters: AuthUserFiltersMany!, first: Int, last: Int, sort: AuthUserSort!): QueryAuthUserFindManyConnection! 63 | authUserFindUnique(where: AuthUserWhereUnique!): AuthUser 64 | 65 | """Current application version""" 66 | version: String! 67 | } 68 | 69 | type QueryAuthUserFindManyConnection { 70 | edges: [QueryAuthUserFindManyConnectionEdge]! 71 | pageInfo: PageInfo! 72 | totalCount: Int! 73 | } 74 | 75 | type QueryAuthUserFindManyConnectionEdge { 76 | cursor: String! 77 | node: AuthUser! 78 | } 79 | 80 | enum SortOrder { 81 | asc 82 | desc 83 | } 84 | 85 | scalar Upload -------------------------------------------------------------------------------- /graphql/server/api/graphql.ts: -------------------------------------------------------------------------------- 1 | import { createYoga } from "graphql-yoga"; 2 | 3 | export default defineEventHandler(async (event) => { 4 | const yoga = createYoga({ 5 | schema, 6 | context: getContext(event), 7 | graphqlEndpoint: "/api/graphql", 8 | graphiql: process.env.NODE_ENV !== "production", 9 | }); 10 | return yoga.handle(event.node.req, event.node.res); 11 | }); 12 | -------------------------------------------------------------------------------- /graphql/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.server.json" 3 | } -------------------------------------------------------------------------------- /graphql/server/types/prisma.ts: -------------------------------------------------------------------------------- 1 | import { Prisma } from "@prisma/client"; 2 | 3 | export const SortOrderEnum = builder.enumType(Prisma.SortOrder, { name: "SortOrder" }); 4 | -------------------------------------------------------------------------------- /graphql/server/types/scalars.ts: -------------------------------------------------------------------------------- 1 | import { DateTimeResolver } from "graphql-scalars"; 2 | 3 | export type Scalars = { 4 | DateTime: { Input: Date; Output: Date }; 5 | Upload: { Input: File; Output: never }; 6 | }; 7 | 8 | export const DateTimeScalar = builder.addScalarType("DateTime", DateTimeResolver, {}); 9 | 10 | export const UploadScalar = builder.scalarType("Upload", { 11 | serialize: () => { 12 | throw new Error("Uploads can only be used as input types"); 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /graphql/server/utils/auth-scopes.ts: -------------------------------------------------------------------------------- 1 | import { AuthRole } from "~/graphql/utils/graphql"; 2 | 3 | export type AuthScopes = { 4 | public: boolean; 5 | isAuthenticated: boolean; 6 | hasAuthRole: AuthRole; 7 | }; 8 | 9 | export const authScopes = async (context: Context) => ({ 10 | public: true, 11 | isAuthenticated: !!context.session?.user, 12 | hasAuthRole: (role: AuthRole) => (context.session?.user ? [AuthRole.Administrator, role].includes(context.session.user.role) : false), 13 | }); 14 | -------------------------------------------------------------------------------- /graphql/server/utils/builder.ts: -------------------------------------------------------------------------------- 1 | import SchemaBuilder from "@pothos/core"; 2 | // eslint-disable-next-line import/no-named-as-default 3 | import PrismaPlugin from "@pothos/plugin-prisma"; 4 | import type PrismaTypes from "@pothos/plugin-prisma/generated"; 5 | import PrismaUtils from "@pothos/plugin-prisma-utils"; 6 | import RelayPlugin from "@pothos/plugin-relay"; 7 | import ScopeAuthPlugin from "@pothos/plugin-scope-auth"; 8 | import SimpleObjectsPlugin from "@pothos/plugin-simple-objects"; 9 | import { Prisma } from "@prisma/client"; 10 | 11 | import { type Scalars } from "~/graphql/server/types/scalars"; 12 | import { type Context } from "~/graphql/server/utils/context"; 13 | 14 | // Pothos Schema Builder 15 | export const builder = new SchemaBuilder<{ 16 | AuthScopes: AuthScopes; 17 | Context: Context; 18 | PrismaTypes: PrismaTypes; 19 | Scalars: Scalars; 20 | }>({ 21 | authScopes, 22 | plugins: [ 23 | ScopeAuthPlugin, // This plugin should always come first 24 | PrismaPlugin, 25 | PrismaUtils, 26 | RelayPlugin, 27 | SimpleObjectsPlugin, 28 | ], 29 | prisma: { 30 | client: prisma, 31 | dmmf: Prisma.dmmf, 32 | exposeDescriptions: true, 33 | filterConnectionTotalCount: true, 34 | onUnusedQuery: process.env.NODE_ENV === "production" ? null : "warn", 35 | }, 36 | relayOptions: { 37 | clientMutationId: "omit", 38 | cursorType: "String", 39 | idFieldName: "globalId", 40 | decodeGlobalID, 41 | encodeGlobalID, 42 | nodeQueryOptions: false, 43 | nodesQueryOptions: false, 44 | }, 45 | scopeAuthOptions: { authorizeOnSubscribe: true }, 46 | }); 47 | -------------------------------------------------------------------------------- /graphql/server/utils/context.ts: -------------------------------------------------------------------------------- 1 | import { type H3Event } from "h3"; 2 | 3 | // GraphQL Context 4 | export function getContext(event: H3Event) { 5 | return { 6 | prisma, 7 | session: event.context.session, 8 | }; 9 | } 10 | 11 | export type Context = ReturnType; 12 | -------------------------------------------------------------------------------- /graphql/server/utils/relay.ts: -------------------------------------------------------------------------------- 1 | export const encodeGlobalID = (typename: string, id: string | number | bigint) => `${typename}:${id}`; 2 | export const decodeGlobalID = (globalID: string) => { 3 | const [typename, id] = globalID.split(":"); 4 | return { typename, id }; 5 | }; 6 | -------------------------------------------------------------------------------- /graphql/server/utils/schema.ts: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from "fs"; 2 | import { lexicographicSortSchema, printSchema } from "graphql"; 3 | 4 | import * as appInfo from "~/app/server/types/app-info"; 5 | import * as authUser from "~/auth/server/types/auth-user"; 6 | import * as prisma from "~/graphql/server/types/prisma"; 7 | import * as scalars from "~/graphql/server/types/scalars"; 8 | import { AuthRole } from "~/graphql/utils/graphql"; 9 | 10 | // Initialize builder 11 | builder.queryType({ authScopes: { hasAuthRole: AuthRole.Administrator } }); 12 | builder.mutationType({ authScopes: { hasAuthRole: AuthRole.Administrator } }); 13 | //builder.subscriptionType({}); 14 | Function.prototype({ appInfo, authUser, prisma, scalars }); 15 | 16 | export const schema = builder.toSchema(); 17 | 18 | // Save GraphQL schema to file 19 | if (process.env.NODE_ENV !== "production") { 20 | const schemaAsString = printSchema(lexicographicSortSchema(schema)); 21 | writeFileSync("graphql/schema.graphql", schemaAsString); 22 | } 23 | -------------------------------------------------------------------------------- /graphql/utils/gql.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import * as types from './graphql'; 3 | import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; 4 | 5 | /** 6 | * Map of all GraphQL operations in the project. 7 | * 8 | * This map has several performance disadvantages: 9 | * 1. It is not tree-shakeable, so it will include all operations in the project. 10 | * 2. It is not minifiable, so the string of a GraphQL query will be multiple times inside the bundle. 11 | * 3. It does not support dead code elimination, so it will add unused operations. 12 | * 13 | * Therefore it is highly recommended to use the babel or swc plugin for production. 14 | */ 15 | const documents = { 16 | "\n query Version {\n version\n }\n ": types.VersionDocument, 17 | "\n mutation Ping {\n ping\n }\n ": types.PingDocument, 18 | "\n fragment TheAuthUser on AuthUser {\n id\n email\n role\n }\n": types.TheAuthUserFragmentDoc, 19 | "\n query TheAuthUsers($filters: AuthUserFiltersMany!, $sort: AuthUserSort!, $after: String, $before: String, $first: Int, $last: Int) {\n authUserFindMany(filters: $filters, sort: $sort, after: $after, before: $before, first: $first, last: $last) {\n edges {\n node {\n ...TheAuthUser\n }\n }\n totalCount\n pageInfo {\n ...PageInfo\n }\n }\n }\n ": types.TheAuthUsersDocument, 20 | "\n mutation AuthUserCreate($data: AuthUserCreateInput!) {\n authUserCreate(data: $data) {\n ...TheAuthUser\n }\n }\n ": types.AuthUserCreateDocument, 21 | "\n mutation AuthUserDestroyMany($authUserIds: [String!]!) {\n authUserDestroyMany(authUserIds: $authUserIds)\n }\n ": types.AuthUserDestroyManyDocument, 22 | "\n fragment PageInfo on PageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n": types.PageInfoFragmentDoc, 23 | }; 24 | 25 | /** 26 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. 27 | * 28 | * 29 | * @example 30 | * ```ts 31 | * const query = graphql(`query GetUser($id: ID!) { user(id: $id) { name } }`); 32 | * ``` 33 | * 34 | * The query argument is unknown! 35 | * Please regenerate the types. 36 | */ 37 | export function graphql(source: string): unknown; 38 | 39 | /** 40 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. 41 | */ 42 | export function graphql(source: "\n query Version {\n version\n }\n "): (typeof documents)["\n query Version {\n version\n }\n "]; 43 | /** 44 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. 45 | */ 46 | export function graphql(source: "\n mutation Ping {\n ping\n }\n "): (typeof documents)["\n mutation Ping {\n ping\n }\n "]; 47 | /** 48 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. 49 | */ 50 | export function graphql(source: "\n fragment TheAuthUser on AuthUser {\n id\n email\n role\n }\n"): (typeof documents)["\n fragment TheAuthUser on AuthUser {\n id\n email\n role\n }\n"]; 51 | /** 52 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. 53 | */ 54 | export function graphql(source: "\n query TheAuthUsers($filters: AuthUserFiltersMany!, $sort: AuthUserSort!, $after: String, $before: String, $first: Int, $last: Int) {\n authUserFindMany(filters: $filters, sort: $sort, after: $after, before: $before, first: $first, last: $last) {\n edges {\n node {\n ...TheAuthUser\n }\n }\n totalCount\n pageInfo {\n ...PageInfo\n }\n }\n }\n "): (typeof documents)["\n query TheAuthUsers($filters: AuthUserFiltersMany!, $sort: AuthUserSort!, $after: String, $before: String, $first: Int, $last: Int) {\n authUserFindMany(filters: $filters, sort: $sort, after: $after, before: $before, first: $first, last: $last) {\n edges {\n node {\n ...TheAuthUser\n }\n }\n totalCount\n pageInfo {\n ...PageInfo\n }\n }\n }\n "]; 55 | /** 56 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. 57 | */ 58 | export function graphql(source: "\n mutation AuthUserCreate($data: AuthUserCreateInput!) {\n authUserCreate(data: $data) {\n ...TheAuthUser\n }\n }\n "): (typeof documents)["\n mutation AuthUserCreate($data: AuthUserCreateInput!) {\n authUserCreate(data: $data) {\n ...TheAuthUser\n }\n }\n "]; 59 | /** 60 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. 61 | */ 62 | export function graphql(source: "\n mutation AuthUserDestroyMany($authUserIds: [String!]!) {\n authUserDestroyMany(authUserIds: $authUserIds)\n }\n "): (typeof documents)["\n mutation AuthUserDestroyMany($authUserIds: [String!]!) {\n authUserDestroyMany(authUserIds: $authUserIds)\n }\n "]; 63 | /** 64 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. 65 | */ 66 | export function graphql(source: "\n fragment PageInfo on PageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n"): (typeof documents)["\n fragment PageInfo on PageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n"]; 67 | 68 | export function graphql(source: string) { 69 | return (documents as any)[source] ?? {}; 70 | } 71 | 72 | export type DocumentType> = TDocumentNode extends DocumentNode< infer TType, any> ? TType : never; -------------------------------------------------------------------------------- /graphql/utils/graphql.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; 3 | export type Maybe = T | null; 4 | export type InputMaybe = Maybe; 5 | export type Exact = { [K in keyof T]: T[K] }; 6 | export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; 7 | export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; 8 | export type MakeEmpty = { [_ in K]?: never }; 9 | export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; 10 | /** All built-in and custom scalars, mapped to their actual values */ 11 | export type Scalars = { 12 | ID: { input: string; output: string; } 13 | String: { input: string; output: string; } 14 | Boolean: { input: boolean; output: boolean; } 15 | Int: { input: number; output: number; } 16 | Float: { input: number; output: number; } 17 | /** A date-time string at UTC, such as 2007-12-03T10:15:30Z, compliant with the `date-time` format outlined in section 5.6 of the RFC 3339 profile of the ISO 8601 standard for representation of dates and times using the Gregorian calendar. */ 18 | DateTime: { input: string; output: string; } 19 | Upload: { input: File; output: File; } 20 | }; 21 | 22 | export enum AuthRole { 23 | Administrator = 'ADMINISTRATOR', 24 | Unverified = 'UNVERIFIED', 25 | Verified = 'VERIFIED' 26 | } 27 | 28 | export type AuthUser = Node & { 29 | __typename?: 'AuthUser'; 30 | email: Scalars['String']['output']; 31 | globalId: Scalars['ID']['output']; 32 | id: Scalars['ID']['output']; 33 | role: AuthRole; 34 | }; 35 | 36 | export type AuthUserCreateInput = { 37 | email: Scalars['String']['input']; 38 | password: Scalars['String']['input']; 39 | role: AuthRole; 40 | }; 41 | 42 | export type AuthUserFiltersMany = { 43 | role?: InputMaybe; 44 | search?: InputMaybe; 45 | }; 46 | 47 | export type AuthUserSort = { 48 | by: AuthUserSortBy; 49 | order: SortOrder; 50 | }; 51 | 52 | export enum AuthUserSortBy { 53 | Email = 'email', 54 | Role = 'role' 55 | } 56 | 57 | export type AuthUserWhereUnique = { 58 | email: Scalars['String']['input']; 59 | }; 60 | 61 | export type Mutation = { 62 | __typename?: 'Mutation'; 63 | authUserCreate?: Maybe; 64 | authUserDestroyMany?: Maybe; 65 | ping: Scalars['String']['output']; 66 | }; 67 | 68 | 69 | export type MutationAuthUserCreateArgs = { 70 | data: AuthUserCreateInput; 71 | }; 72 | 73 | 74 | export type MutationAuthUserDestroyManyArgs = { 75 | authUserIds: Array; 76 | }; 77 | 78 | export type Node = { 79 | globalId: Scalars['ID']['output']; 80 | }; 81 | 82 | export type PageInfo = { 83 | __typename?: 'PageInfo'; 84 | endCursor?: Maybe; 85 | hasNextPage: Scalars['Boolean']['output']; 86 | hasPreviousPage: Scalars['Boolean']['output']; 87 | startCursor?: Maybe; 88 | }; 89 | 90 | export type Query = { 91 | __typename?: 'Query'; 92 | authUserFindMany: QueryAuthUserFindManyConnection; 93 | authUserFindUnique?: Maybe; 94 | /** Current application version */ 95 | version: Scalars['String']['output']; 96 | }; 97 | 98 | 99 | export type QueryAuthUserFindManyArgs = { 100 | after?: InputMaybe; 101 | before?: InputMaybe; 102 | filters: AuthUserFiltersMany; 103 | first?: InputMaybe; 104 | last?: InputMaybe; 105 | sort: AuthUserSort; 106 | }; 107 | 108 | 109 | export type QueryAuthUserFindUniqueArgs = { 110 | where: AuthUserWhereUnique; 111 | }; 112 | 113 | export type QueryAuthUserFindManyConnection = { 114 | __typename?: 'QueryAuthUserFindManyConnection'; 115 | edges: Array>; 116 | pageInfo: PageInfo; 117 | totalCount: Scalars['Int']['output']; 118 | }; 119 | 120 | export type QueryAuthUserFindManyConnectionEdge = { 121 | __typename?: 'QueryAuthUserFindManyConnectionEdge'; 122 | cursor: Scalars['String']['output']; 123 | node: AuthUser; 124 | }; 125 | 126 | export enum SortOrder { 127 | Asc = 'asc', 128 | Desc = 'desc' 129 | } 130 | 131 | export type VersionQueryVariables = Exact<{ [key: string]: never; }>; 132 | 133 | 134 | export type VersionQuery = { __typename?: 'Query', version: string }; 135 | 136 | export type PingMutationVariables = Exact<{ [key: string]: never; }>; 137 | 138 | 139 | export type PingMutation = { __typename?: 'Mutation', ping: string }; 140 | 141 | export type TheAuthUserFragment = { __typename?: 'AuthUser', id: string, email: string, role: AuthRole }; 142 | 143 | export type TheAuthUsersQueryVariables = Exact<{ 144 | filters: AuthUserFiltersMany; 145 | sort: AuthUserSort; 146 | after?: InputMaybe; 147 | before?: InputMaybe; 148 | first?: InputMaybe; 149 | last?: InputMaybe; 150 | }>; 151 | 152 | 153 | export type TheAuthUsersQuery = { __typename?: 'Query', authUserFindMany: { __typename?: 'QueryAuthUserFindManyConnection', totalCount: number, edges: Array<{ __typename?: 'QueryAuthUserFindManyConnectionEdge', node: { __typename?: 'AuthUser', id: string, email: string, role: AuthRole } } | null>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } }; 154 | 155 | export type AuthUserCreateMutationVariables = Exact<{ 156 | data: AuthUserCreateInput; 157 | }>; 158 | 159 | 160 | export type AuthUserCreateMutation = { __typename?: 'Mutation', authUserCreate?: { __typename?: 'AuthUser', id: string, email: string, role: AuthRole } | null }; 161 | 162 | export type AuthUserDestroyManyMutationVariables = Exact<{ 163 | authUserIds: Array | Scalars['String']['input']; 164 | }>; 165 | 166 | 167 | export type AuthUserDestroyManyMutation = { __typename?: 'Mutation', authUserDestroyMany?: number | null }; 168 | 169 | export type PageInfoFragment = { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null }; 170 | 171 | export const TheAuthUserFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TheAuthUser"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AuthUser"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]} as unknown as DocumentNode; 172 | export const PageInfoFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PageInfo"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PageInfo"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"hasPreviousPage"}},{"kind":"Field","name":{"kind":"Name","value":"startCursor"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]} as unknown as DocumentNode; 173 | export const VersionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Version"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"version"}}]}}]} as unknown as DocumentNode; 174 | export const PingDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"Ping"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ping"}}]}}]} as unknown as DocumentNode; 175 | export const TheAuthUsersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"TheAuthUsers"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filters"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AuthUserFiltersMany"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sort"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AuthUserSort"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"before"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"last"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"authUserFindMany"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filters"}}},{"kind":"Argument","name":{"kind":"Name","value":"sort"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sort"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}},{"kind":"Argument","name":{"kind":"Name","value":"before"},"value":{"kind":"Variable","name":{"kind":"Name","value":"before"}}},{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}},{"kind":"Argument","name":{"kind":"Name","value":"last"},"value":{"kind":"Variable","name":{"kind":"Name","value":"last"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TheAuthUser"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PageInfo"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TheAuthUser"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AuthUser"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PageInfo"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PageInfo"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"hasPreviousPage"}},{"kind":"Field","name":{"kind":"Name","value":"startCursor"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]} as unknown as DocumentNode; 176 | export const AuthUserCreateDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AuthUserCreate"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"data"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AuthUserCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"authUserCreate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"data"},"value":{"kind":"Variable","name":{"kind":"Name","value":"data"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TheAuthUser"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TheAuthUser"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AuthUser"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"role"}}]}}]} as unknown as DocumentNode; 177 | export const AuthUserDestroyManyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AuthUserDestroyMany"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"authUserIds"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"authUserDestroyMany"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"authUserIds"},"value":{"kind":"Variable","name":{"kind":"Name","value":"authUserIds"}}}]}]}}]} as unknown as DocumentNode; -------------------------------------------------------------------------------- /graphql/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./gql"; 2 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | export default defineNuxtConfig({ 3 | extends: ["./app", "./auth", "./graphql", "./prisma", "./ui"], 4 | typescript: { typeCheck: true }, 5 | vite: { build: { sourcemap: process.env.NODE_ENV === "production" ? true : "inline" } }, 6 | }); 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@lewebsimple/nuxt-graphql-fullstack", 3 | "description": "Nuxt 3 GraphQL fullstack boilerplate", 4 | "version": "0.2.6", 5 | "author": "Pascal Martineau ", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/lewebsimple/nuxt-graphql-fullstack.git" 9 | }, 10 | "scripts": { 11 | "build": "nuxt build", 12 | "dev": "nuxt dev --host 0.0.0.0 --no-clear", 13 | "lint": "eslint --fix . && stylelint --fix **/*.{css,scss,vue}", 14 | "prepare": "husky install && prisma generate && nuxt prepare", 15 | "release": "pnpm lint && changelogen --release --push" 16 | }, 17 | "prisma": { 18 | "seed": "tsx prisma/server/seed.ts" 19 | }, 20 | "dependencies": { 21 | "@lewebsimple/nuxt3-svg": "^0.2.2", 22 | "@lucia-auth/adapter-prisma": "^3.0.2", 23 | "@nuxt/ui": "^2.12.0", 24 | "@pothos/core": "^3.41.0", 25 | "@pothos/plugin-prisma": "^3.63.1", 26 | "@pothos/plugin-prisma-utils": "^0.14.0", 27 | "@pothos/plugin-relay": "^3.45.1", 28 | "@pothos/plugin-scope-auth": "^3.20.0", 29 | "@pothos/plugin-simple-objects": "^3.7.0", 30 | "@prisma/client": "^5.8.0", 31 | "@urql/vue": "^1.1.2", 32 | "bullmq": "^5.1.1", 33 | "graphql": "^16.8.1", 34 | "graphql-scalars": "^1.22.4", 35 | "graphql-yoga": "^5.1.1", 36 | "lucia": "^2.7.6", 37 | "luxon": "^3.4.4", 38 | "zod": "^3.22.4" 39 | }, 40 | "devDependencies": { 41 | "@graphql-codegen/cli": "^5.0.0", 42 | "@graphql-codegen/client-preset": "^4.1.0", 43 | "@graphql-codegen/schema-ast": "^4.0.0", 44 | "@lewebsimple/eslint-config": "^0.9.1", 45 | "@lewebsimple/stylelint-config": "^0.9.1", 46 | "@nuxt/devtools": "latest", 47 | "@types/luxon": "^3.4.0", 48 | "@types/node": "^20.10.8", 49 | "changelogen": "^0.5.5", 50 | "eslint": "^8.56.0", 51 | "graphql-config": "^5.0.3", 52 | "husky": "^8.0.3", 53 | "nano-staged": "^0.8.0", 54 | "nuxt": "^3.9.1", 55 | "prisma": "^5.8.0", 56 | "stylelint": "^16.1.0", 57 | "tsx": "^4.7.0", 58 | "vite-plugin-graphql-codegen": "^3.3.6", 59 | "vue-tsc": "^1.8.27" 60 | }, 61 | "eslintConfig": { 62 | "extends": "@lewebsimple/eslint-config" 63 | }, 64 | "stylelint": { 65 | "extends": "@lewebsimple/stylelint-config" 66 | }, 67 | "nano-staged": { 68 | "*.{js,ts,vue}": "eslint --fix", 69 | "*.{css,scss,vue}": "stylelint --fix" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /prisma/migrations/20231115160637_auth/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `AuthUser` ( 3 | `id` VARCHAR(191) NOT NULL, 4 | `email` VARCHAR(191) NOT NULL, 5 | `role` ENUM('UNVERIFIED', 'VERIFIED', 'ADMINISTRATOR') NOT NULL DEFAULT 'UNVERIFIED', 6 | 7 | UNIQUE INDEX `AuthUser_email_key`(`email`), 8 | PRIMARY KEY (`id`) 9 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 10 | 11 | -- CreateTable 12 | CREATE TABLE `AuthSession` ( 13 | `id` VARCHAR(191) NOT NULL, 14 | `user_id` VARCHAR(191) NOT NULL, 15 | `active_expires` BIGINT NOT NULL, 16 | `idle_expires` BIGINT NOT NULL, 17 | `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 18 | 19 | INDEX `AuthSession_user_id_idx`(`user_id`), 20 | PRIMARY KEY (`id`) 21 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 22 | 23 | -- CreateTable 24 | CREATE TABLE `AuthKey` ( 25 | `id` VARCHAR(191) NOT NULL, 26 | `hashed_password` VARCHAR(191) NULL, 27 | `user_id` VARCHAR(191) NOT NULL, 28 | 29 | INDEX `AuthKey_user_id_idx`(`user_id`), 30 | PRIMARY KEY (`id`) 31 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 32 | 33 | -- AddForeignKey 34 | ALTER TABLE `AuthSession` ADD CONSTRAINT `AuthSession_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `AuthUser`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; 35 | 36 | -- AddForeignKey 37 | ALTER TABLE `AuthKey` ADD CONSTRAINT `AuthKey_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `AuthUser`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; 38 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "mysql" -------------------------------------------------------------------------------- /prisma/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import { createRequire } from "module"; 2 | 3 | // @see https://github.com/prisma/prisma/issues/12504#issuecomment-1365267088 4 | const require = createRequire(import.meta.url); 5 | const pathName = require.resolve("@prisma/client").replace("@prisma/client/index.js", ""); 6 | 7 | export default defineNuxtConfig({ 8 | vite: { resolve: { alias: { ".prisma/client/index-browser": `${pathName}.prisma/client/index-browser.js` } } }, 9 | }); 10 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | datasource db { 2 | provider = "mysql" 3 | url = env("DATABASE_URL") 4 | } 5 | 6 | generator client { 7 | provider = "prisma-client-js" 8 | } 9 | 10 | generator pothos { 11 | provider = "prisma-pothos-types" 12 | prismaUtils = true 13 | } 14 | 15 | enum AuthRole { 16 | UNVERIFIED 17 | VERIFIED 18 | ADMINISTRATOR 19 | } 20 | 21 | model AuthUser { 22 | id String @id 23 | 24 | auth_session AuthSession[] 25 | auth_key AuthKey[] 26 | 27 | email String @unique 28 | role AuthRole @default(UNVERIFIED) 29 | } 30 | 31 | model AuthSession { 32 | id String @id 33 | user_id String 34 | active_expires BigInt 35 | idle_expires BigInt 36 | user AuthUser @relation(references: [id], fields: [user_id], onDelete: Cascade) 37 | 38 | created_at DateTime @default(now()) 39 | 40 | @@index([user_id]) 41 | } 42 | 43 | model AuthKey { 44 | id String @id 45 | hashed_password String? 46 | user_id String 47 | user AuthUser @relation(references: [id], fields: [user_id], onDelete: Cascade) 48 | 49 | @@index([user_id]) 50 | } 51 | -------------------------------------------------------------------------------- /prisma/server/seed.ts: -------------------------------------------------------------------------------- 1 | import { seedAdminUser } from "~/auth/server/seeds/admin-user"; 2 | import { prisma, type PrismaClient } from "~/prisma/server/utils/prisma"; 3 | 4 | export type SeedFn = (prisma: PrismaClient) => Promise; 5 | 6 | const seeds: Record = { 7 | seedAdminUser, 8 | }; 9 | 10 | async function main() { 11 | for (const [seedName, seedFn] of Object.entries(seeds)) { 12 | console.log("\n🌱 " + seedName); 13 | console.log(await seedFn(prisma)); 14 | } 15 | } 16 | 17 | main() 18 | .catch((e) => { 19 | console.error(e); 20 | process.exit(1); 21 | }) 22 | .finally(async () => { 23 | await prisma.$disconnect(); 24 | }); 25 | -------------------------------------------------------------------------------- /prisma/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.server.json" 3 | } -------------------------------------------------------------------------------- /prisma/server/utils/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | export { type Prisma, type PrismaClient } from "@prisma/client"; 4 | 5 | export const prisma = new PrismaClient(); 6 | -------------------------------------------------------------------------------- /scripts/post-deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | export PATH=$PATH:/opt/plesk/node/20/bin 5 | 6 | LOG=~/logs/deploy.log 7 | 8 | echo "Running post-deploy script" > $LOG 9 | pnpm install >> $LOG 10 | pnpm build >> $LOG 11 | pnpm prisma migrate deploy >> $LOG 12 | touch tmp/restart.txt 13 | echo "Finished post-deploy script" >> $LOG -------------------------------------------------------------------------------- /scripts/server.cjs: -------------------------------------------------------------------------------- 1 | (async function main() { 2 | await import("../.output/server/index.mjs"); 3 | })(); 4 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | export default >{ 4 | theme: { 5 | extend: { 6 | container: { center: true, padding: "1rem" }, 7 | }, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.server.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.server.json", 4 | "exclude": [ 5 | "scripts" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /types/svg.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.svg" { 2 | import type { DefineComponent } from "vue"; 3 | 4 | const component: DefineComponent; 5 | export default component; 6 | } 7 | -------------------------------------------------------------------------------- /ui/assets/css/colors.css: -------------------------------------------------------------------------------- 1 | @layer base { 2 | body { 3 | @apply bg-white dark:bg-black; 4 | } 5 | 6 | *, 7 | ::before, 8 | ::after { 9 | @apply border-gray-200 dark:border-gray-800; 10 | @apply divide-gray-200 dark:divide-gray-800; 11 | } 12 | } 13 | 14 | @layer utilities { 15 | .bg-muted { 16 | @apply bg-gray-100 dark:bg-gray-900; 17 | } 18 | 19 | .text-muted { 20 | @apply text-gray-500; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /ui/assets/css/forms.css: -------------------------------------------------------------------------------- 1 | @layer components { 2 | .form-wrapper { 3 | @apply space-y-4; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /ui/assets/css/main.css: -------------------------------------------------------------------------------- 1 | @import "tailwind"; 2 | @import "colors"; 3 | @import "forms"; 4 | @import "transitions"; 5 | @import "typography"; 6 | -------------------------------------------------------------------------------- /ui/assets/css/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /ui/assets/css/transitions.css: -------------------------------------------------------------------------------- 1 | .slide-left-enter-active, 2 | .slide-left-leave-active, 3 | .slide-right-enter-active, 4 | .slide-right-leave-active { 5 | transition: all 0.2s; 6 | } 7 | 8 | .slide-left-enter-from { 9 | opacity: 0; 10 | transform: translate(50px, 0); 11 | } 12 | 13 | .slide-left-leave-to { 14 | opacity: 0; 15 | transform: translate(50px, 0); 16 | } 17 | 18 | .slide-right-enter-from { 19 | opacity: 0; 20 | transform: translate(-50px, 0); 21 | } 22 | 23 | .slide-right-leave-to { 24 | opacity: 0; 25 | transform: translate(-50px, 0); 26 | } 27 | 28 | .fade-enter-from { 29 | opacity: 0; 30 | } 31 | 32 | .fade-leave-to { 33 | opacity: 0; 34 | } 35 | -------------------------------------------------------------------------------- /ui/assets/css/typography.css: -------------------------------------------------------------------------------- 1 | @layer utilities { 2 | .h1 { 3 | @apply text-4xl lg:text-5xl font-extrabold tracking-tight; 4 | } 5 | 6 | .h2 { 7 | @apply text-3xl font-semibold tracking-tight; 8 | } 9 | 10 | .h3 { 11 | @apply text-2xl font-semibold tracking-tight; 12 | } 13 | 14 | .h4 { 15 | @apply text-xl font-semibold tracking-tight; 16 | } 17 | 18 | .link-default { 19 | @apply text-primary hover:underline; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /ui/components/actions/UActionModal.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 17 | -------------------------------------------------------------------------------- /ui/components/actions/UActionsDropdown.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 52 | -------------------------------------------------------------------------------- /ui/components/helpers/UDate.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 31 | -------------------------------------------------------------------------------- /ui/components/helpers/UFormWrapper.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 69 | -------------------------------------------------------------------------------- /ui/components/helpers/UTotalCount.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | -------------------------------------------------------------------------------- /ui/components/inputs/UPasswordInput.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 15 | -------------------------------------------------------------------------------- /ui/components/inputs/USearchInput.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | -------------------------------------------------------------------------------- /ui/components/inputs/USelectOptional.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 10 | -------------------------------------------------------------------------------- /ui/components/navigation/UCursorPagination.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 13 | -------------------------------------------------------------------------------- /ui/components/navigation/UTableSortHeader.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 26 | -------------------------------------------------------------------------------- /ui/composables/dark-mode.ts: -------------------------------------------------------------------------------- 1 | export function useDarkMode() { 2 | const colorMode = useColorMode(); 3 | return { 4 | isDarkMode: computed({ 5 | get: () => colorMode.value === "dark", 6 | set: (newIsDark) => (colorMode.preference = newIsDark ? "dark" : "light"), 7 | }), 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /ui/composables/toaster.ts: -------------------------------------------------------------------------------- 1 | export function useToaster() { 2 | const { add } = useToast(); 3 | const success = (description: string) => add({ title: "Succès", description, color: "green", icon: "i-heroicons-check-circle" }); 4 | const error = (description: string) => add({ title: "Erreur", description, color: "red", icon: "i-heroicons-x-circle" }); 5 | const info = (title: string, description: string) => add({ title, description, color: "primary", icon: "i-heroicons-info-circle" }); 6 | return { success, error, info }; 7 | } 8 | -------------------------------------------------------------------------------- /ui/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtConfig({ 2 | components: [{ path: "~/ui/components", pathPrefix: false }], 3 | modules: ["@nuxt/ui"], 4 | tailwindcss: { 5 | cssPath: "~/ui/assets/css/main.css", 6 | viewer: false, 7 | }, 8 | }); 9 | --------------------------------------------------------------------------------