├── public ├── robots.txt └── favicon.ico ├── .dockerignore ├── assets └── styles │ ├── main.css │ └── components │ └── button.css ├── graphql ├── hello.query.graphql └── ping.subscription.graphql ├── .gitignore ├── server ├── nexus │ ├── _types.ts │ ├── hello.ts │ ├── ping.ts │ ├── user.ts │ └── _rules.ts ├── pubsub.ts ├── api │ ├── logout.ts │ ├── login.ts │ └── graphql.ts ├── nitro-preset.ts ├── context.ts ├── schema.ts ├── bootstrap.ts └── entries │ └── node.ts ├── tsconfig.json ├── layouts └── default.vue ├── prisma ├── migrations │ ├── migration_lock.toml │ └── 20220223132958_initial_user_model │ │ └── migration.sql ├── nexus.ts ├── client.ts ├── schema.prisma └── seed.ts ├── middleware ├── is-not-authenticated.ts ├── is-authenticated.ts └── has-user-role.ts ├── pages ├── secret.vue ├── login.vue └── index.vue ├── components ├── form │ ├── logout.vue │ └── login.vue └── nav │ └── menu.vue ├── utils ├── password.ts └── jwt.ts ├── plugins ├── auth.server.ts └── urql.ts ├── modules └── bootstrap.ts ├── generated ├── schema.graphql ├── schema.ts └── nexus-types.ts ├── formkit.config.ts ├── codegen.yml ├── README.md ├── tailwind.config.js ├── Dockerfile ├── nuxt.config.ts ├── composables ├── auth.ts ├── hello.query.ts └── ping.subscription.ts └── package.json /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .env 2 | .nuxt 3 | .output 4 | node_modules 5 | -------------------------------------------------------------------------------- /assets/styles/main.css: -------------------------------------------------------------------------------- 1 | @import "components/button.css"; 2 | -------------------------------------------------------------------------------- /graphql/hello.query.graphql: -------------------------------------------------------------------------------- 1 | query Hello { 2 | hello 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | .nuxt 4 | nuxt.d.ts 5 | .output 6 | .env -------------------------------------------------------------------------------- /graphql/ping.subscription.graphql: -------------------------------------------------------------------------------- 1 | subscription Ping { 2 | ping 3 | } 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lewebsimple/nuxt3-fullstack/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /server/nexus/_types.ts: -------------------------------------------------------------------------------- 1 | export * from "./hello"; 2 | export * from "./ping"; 3 | export * from "./user"; 4 | -------------------------------------------------------------------------------- /server/pubsub.ts: -------------------------------------------------------------------------------- 1 | import { PubSub } from "graphql-subscriptions"; 2 | 3 | export const pubsub = new PubSub(); 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://v3.nuxtjs.org/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /layouts/default.vue: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /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/nexus.ts: -------------------------------------------------------------------------------- 1 | import NP, * as NPS from "nexus-prisma/dist-cjs/entrypoints/main"; 2 | 3 | export const User = NP?.User || NPS?.User; 4 | export const UserRole = NP?.UserRole || NPS?.UserRole; 5 | -------------------------------------------------------------------------------- /server/api/logout.ts: -------------------------------------------------------------------------------- 1 | import { defineHandle } from "h3"; 2 | import { setAuthState } from "~/utils/jwt"; 3 | 4 | export default defineHandle((_req, res) => { 5 | return setAuthState(null, res); 6 | }); 7 | -------------------------------------------------------------------------------- /server/nitro-preset.ts: -------------------------------------------------------------------------------- 1 | import type { NitroPreset } from "@nuxt/nitro"; 2 | 3 | export default { 4 | entry: "server/entries/node", 5 | externals: true, 6 | serveStatic: true, 7 | } as NitroPreset; 8 | -------------------------------------------------------------------------------- /middleware/is-not-authenticated.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware((to, from) => { 2 | const { isAuthenticated } = useAuth(); 3 | if (isAuthenticated.value) { 4 | return navigateTo(`/`); 5 | } 6 | }); 7 | -------------------------------------------------------------------------------- /middleware/is-authenticated.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware((to, from) => { 2 | const { isAuthenticated } = useAuth(); 3 | if (!isAuthenticated.value) { 4 | return navigateTo(`/login?redirect=${to.fullPath}`); 5 | } 6 | }); 7 | -------------------------------------------------------------------------------- /pages/secret.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /assets/styles/components/button.css: -------------------------------------------------------------------------------- 1 | @layer components { 2 | .btn { 3 | @apply inline-flex px-3 py-1.5 rounded bg-slate-800 hover:bg-slate-600 text-white font-bold cursor-pointer; 4 | } 5 | .btn:disabled { 6 | @apply cursor-not-allowed; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /components/form/logout.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 12 | -------------------------------------------------------------------------------- /utils/password.ts: -------------------------------------------------------------------------------- 1 | import { compareSync, hashSync } from "bcrypt"; 2 | 3 | export const encryptPassword = (password: string): string => hashSync(password, 10); 4 | 5 | export const verifyPassword = (password: string, encrypted: string): boolean => compareSync(password, encrypted); 6 | -------------------------------------------------------------------------------- /server/nexus/hello.ts: -------------------------------------------------------------------------------- 1 | import { extendType } from "nexus"; 2 | 3 | export const HelloQuery = extendType({ 4 | type: "Query", 5 | definition(t) { 6 | t.nonNull.field("hello", { 7 | type: "String", 8 | resolve: () => `Hello Nexus`, 9 | }); 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /plugins/auth.server.ts: -------------------------------------------------------------------------------- 1 | import type { AuthState } from "~/utils/jwt"; 2 | import { jwtCookieName, decodeJwt } from "~/utils/jwt"; 3 | 4 | export default defineNuxtPlugin(() => { 5 | const token = useCookie(jwtCookieName).value; 6 | useState("auth", () => decodeJwt(token)); 7 | }); 8 | -------------------------------------------------------------------------------- /prisma/client.ts: -------------------------------------------------------------------------------- 1 | import { config } from "dotenv"; 2 | import Prisma, * as PrismaScope from "@prisma/client"; 3 | const PrismaClient = Prisma?.PrismaClient || PrismaScope?.PrismaClient; 4 | 5 | // Load process.env.DATABASE_URL from .env 6 | config(); 7 | 8 | export const prisma = new PrismaClient(); 9 | -------------------------------------------------------------------------------- /modules/bootstrap.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtModule } from "@nuxt/kit"; 2 | import { initialize } from "../server/bootstrap"; 3 | 4 | export default defineNuxtModule({ 5 | setup(_options, nuxt) { 6 | nuxt.hook("listen", async (server) => { 7 | await initialize(server); 8 | }); 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /pages/login.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 15 | -------------------------------------------------------------------------------- /generated/schema.graphql: -------------------------------------------------------------------------------- 1 | ### This file was generated by Nexus Schema 2 | ### Do not make changes to this file directly 3 | 4 | 5 | type Query { 6 | hello: String! 7 | } 8 | 9 | type Subscription { 10 | ping: String 11 | } 12 | 13 | type User { 14 | email: String! 15 | id: Int! 16 | role: UserRole! 17 | } 18 | 19 | enum UserRole { 20 | ADMIN 21 | EDITOR 22 | GUEST 23 | UNVERIFIED 24 | } -------------------------------------------------------------------------------- /server/nexus/ping.ts: -------------------------------------------------------------------------------- 1 | import { subscriptionType } from "nexus"; 2 | 3 | export const PingSubscription = subscriptionType({ 4 | definition(t) { 5 | t.string("ping", { 6 | subscribe: (_root, _args, { pubsub }) => { 7 | return pubsub.asyncIterator(["ping"]); 8 | }, 9 | resolve(data: { ping: string }) { 10 | return data.ping; 11 | }, 12 | }); 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /server/nexus/user.ts: -------------------------------------------------------------------------------- 1 | import { enumType, objectType } from "nexus"; 2 | import { User, UserRole } from "../../prisma/nexus"; 3 | 4 | export const UserRoleEnum = enumType(UserRole); 5 | 6 | export const UserObject = objectType({ 7 | name: User.$name, 8 | description: User.$description, 9 | definition(t) { 10 | t.field(User.id); 11 | t.field(User.email); 12 | t.field(User.role); 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /server/context.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from "../prisma/client"; 2 | import type { AuthState } from "../utils/jwt"; 3 | import { decodeJwt } from "../utils/jwt"; 4 | import { pubsub } from "./pubsub"; 5 | 6 | export type Context = { 7 | auth: AuthState; 8 | prisma: typeof prisma; 9 | pubsub: typeof pubsub; 10 | }; 11 | 12 | export const contextFactory = (token: string): Context => { 13 | return { auth: decodeJwt(token), prisma, pubsub }; 14 | }; 15 | -------------------------------------------------------------------------------- /prisma/migrations/20220223132958_initial_user_model/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `User` ( 3 | `id` INTEGER NOT NULL AUTO_INCREMENT, 4 | `email` VARCHAR(191) NOT NULL, 5 | `password` VARCHAR(191) NOT NULL, 6 | `role` ENUM('UNVERIFIED', 'GUEST', 'EDITOR', 'ADMIN') NOT NULL DEFAULT 'UNVERIFIED', 7 | 8 | UNIQUE INDEX `User_email_key`(`email`), 9 | PRIMARY KEY (`id`) 10 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 11 | -------------------------------------------------------------------------------- /pages/index.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 17 | -------------------------------------------------------------------------------- /formkit.config.ts: -------------------------------------------------------------------------------- 1 | import { DefaultConfigOptions } from "@formkit/vue"; 2 | import { generateClasses } from "@formkit/tailwindcss"; 3 | 4 | export default { 5 | config: { 6 | classes: generateClasses({ 7 | global: { 8 | outer: "mb-3", 9 | label: "font-bold", 10 | help: "text-sm text-slate-400", 11 | messages: "mb-3 text-sm text-red-500", 12 | }, 13 | submit: { 14 | input: "btn", 15 | }, 16 | }), 17 | }, 18 | } as DefaultConfigOptions; 19 | -------------------------------------------------------------------------------- /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 nexusPrisma { 11 | provider = "nexus-prisma" 12 | } 13 | 14 | model User { 15 | id Int @id @default(autoincrement()) 16 | email String @unique 17 | password String 18 | role UserRole @default(UNVERIFIED) 19 | } 20 | 21 | enum UserRole { 22 | UNVERIFIED 23 | GUEST 24 | EDITOR 25 | ADMIN 26 | } 27 | -------------------------------------------------------------------------------- /codegen.yml: -------------------------------------------------------------------------------- 1 | schema: generated/schema.graphql 2 | documents: graphql/**/*.graphql 3 | generates: 4 | generated/schema.ts: 5 | plugins: 6 | - typescript 7 | composables/: 8 | preset: near-operation-file 9 | presetConfig: 10 | baseTypesPath: ../generated/schema.ts 11 | extension: .ts 12 | folder: ../composables 13 | config: 14 | documentMode: documentNode 15 | plugins: 16 | - typescript 17 | - typescript-operations 18 | - typescript-vue-urql 19 | hooks: 20 | afterAllFileWrite: 21 | - eslint --fix ./generated ./composables 22 | -------------------------------------------------------------------------------- /middleware/has-user-role.ts: -------------------------------------------------------------------------------- 1 | import type { UserRole } from "@prisma/client"; 2 | 3 | export default defineNuxtRouteMiddleware((to, from) => { 4 | const { isAuthenticated, hasUserRole } = useAuth(); 5 | if (!isAuthenticated.value) { 6 | return navigateTo(`/login?redirect=${to.fullPath}`); 7 | } else if (!hasUserRole(to.meta.hasUserRole || "ADMIN")) { 8 | return abortNavigation("You do not have permission to visit this page."); 9 | } 10 | }); 11 | 12 | declare module "nuxt3/dist/pages/runtime/composables" { 13 | interface PageMeta { 14 | hasUserRole?: UserRole; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nuxt 3 full stack application starter 2 | 3 | This repository contains the example code for the [Exploring Nuxt3 as a full stack solution](https://dev.to/lewebsimple/nuxt3-graphql-4p5l) series. 4 | 5 | ## Setup 6 | 7 | Make sure to install the dependencies 8 | 9 | ```bash 10 | yarn install 11 | ``` 12 | 13 | ## Development 14 | 15 | Start the development server on http://localhost:3000 16 | 17 | ```bash 18 | yarn dev 19 | ``` 20 | 21 | ## Production 22 | 23 | Build the application for production: 24 | 25 | ```bash 26 | yarn build 27 | ``` 28 | 29 | Checkout the [deployment documentation](https://v3.nuxtjs.org/docs/deployment). -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | 3 | module.exports = { 4 | theme: { 5 | extend: { 6 | container: { 7 | center: true, 8 | padding: "1.5rem", 9 | }, 10 | typography: { 11 | DEFAULT: { 12 | css: { 13 | maxWidth: "none", 14 | }, 15 | }, 16 | }, 17 | }, 18 | }, 19 | plugins: [ 20 | require("@tailwindcss/forms"), 21 | require("@tailwindcss/line-clamp"), 22 | require("@tailwindcss/typography"), 23 | require("@formkit/tailwindcss").default, 24 | ], 25 | content: ["formkit.config.ts"], 26 | }; 27 | -------------------------------------------------------------------------------- /components/nav/menu.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 23 | -------------------------------------------------------------------------------- /server/nexus/_rules.ts: -------------------------------------------------------------------------------- 1 | import { generic, ruleType, ShieldCache } from "nexus-shield"; 2 | import type { UserRole } from "@prisma/client"; 3 | 4 | export { and, or, chain, not, race } from "nexus-shield"; 5 | 6 | export const isAuthenticated = generic( 7 | ruleType({ 8 | cache: ShieldCache.CONTEXTUAL, 9 | resolve: (_root, _args, { auth }) => { 10 | return !!auth.user; 11 | }, 12 | }), 13 | ); 14 | 15 | export const hasUserRole = (role: UserRole) => 16 | generic( 17 | ruleType({ 18 | cache: ShieldCache.CONTEXTUAL, 19 | resolve: (_root, _args, { auth }) => { 20 | return [role, "ADMIN"].includes(auth.user?.role || ""); 21 | }, 22 | }), 23 | ); 24 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ### BASE ### 2 | FROM node:16-slim as base 3 | 4 | WORKDIR /app 5 | 6 | RUN apt-get update && apt-get install --no-install-recommends --yes openssl 7 | 8 | COPY package.json yarn.lock ./ 9 | COPY prisma/schema.prisma ./prisma/ 10 | RUN yarn install --pure-lockfile --silent 11 | 12 | ### BUILDER ### 13 | FROM base AS builder 14 | 15 | COPY . ./ 16 | RUN yarn build 17 | 18 | ### RUNNER ### 19 | FROM base 20 | 21 | ENV NODE_ENV production 22 | ENV NUXT_HOST 0.0.0.0 23 | 24 | 25 | COPY --from=builder /app/.output /app/.output 26 | COPY --from=builder /app/node_modules/.prisma/client/ node_modules/.prisma/client 27 | 28 | USER node 29 | 30 | EXPOSE 3000 31 | 32 | CMD ["node", ".output/server/index.mjs"] 33 | -------------------------------------------------------------------------------- /server/api/login.ts: -------------------------------------------------------------------------------- 1 | import { defineHandle, useBody } from "h3"; 2 | import { prisma } from "~/prisma/client"; 3 | import { setAuthState } from "~/utils/jwt"; 4 | import { verifyPassword } from "~/utils/password"; 5 | 6 | export default defineHandle(async (req, res) => { 7 | try { 8 | const { email, password } = await useBody(req); 9 | const user = await prisma.user.findFirst({ where: { email } }); 10 | if (!user) throw new Error("User does not exist."); 11 | if (!verifyPassword(password, user.password)) throw new Error("Invalid password"); 12 | return setAuthState(user, res); 13 | } catch (error) { 14 | res.statusCode = 401; 15 | res.statusMessage = (error as Error).message; 16 | return setAuthState(null, res); 17 | } 18 | }); 19 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "pathe"; 2 | import { defineNuxtConfig } from "nuxt3"; 3 | 4 | // https://v3.nuxtjs.org/docs/directory-structure/nuxt.config 5 | export default defineNuxtConfig({ 6 | alias: { 7 | "nexus-prisma": "nexus-prisma/dist-cjs/entrypoints/main.js", 8 | }, 9 | build: { 10 | transpile: ["@urql/vue"], 11 | }, 12 | css: ["~/assets/styles/main.css"], 13 | modules: ["~/modules/bootstrap", "@nuxtjs/tailwindcss", "@formkit/nuxt"], 14 | nitro: { 15 | preset: resolve(__dirname, "server/nitro-preset.ts"), 16 | }, 17 | publicRuntimeConfig: { 18 | graphqlApiURL: process.env.GRAPHQL_API_URL || "http://localhost:3000/api/graphql", 19 | }, 20 | tailwindcss: { 21 | viewer: false, 22 | }, 23 | typescript: { strict: true }, 24 | }); 25 | -------------------------------------------------------------------------------- /prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient, UserRole } from "@prisma/client"; 2 | import { encryptPassword } from "../utils/password"; 3 | 4 | const prisma = new PrismaClient(); 5 | 6 | async function main() { 7 | // Default admin user 8 | const admin = { 9 | email: process.env.SEED_ADMIN_EMAIL || "admin@example.com", 10 | password: encryptPassword(process.env.SEED_ADMIN_PASSWORD || "changeme"), 11 | role: UserRole.ADMIN, 12 | }; 13 | const user = await prisma.user.upsert({ 14 | where: { email: admin.email }, 15 | create: admin, 16 | update: admin, 17 | }); 18 | console.log(user); 19 | } 20 | 21 | main() 22 | .catch((e) => { 23 | console.error(e); 24 | process.exit(1); 25 | }) 26 | .finally(async () => { 27 | await prisma.$disconnect(); 28 | }); 29 | -------------------------------------------------------------------------------- /components/form/login.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 23 | -------------------------------------------------------------------------------- /server/schema.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "pathe"; 2 | import { GraphQLSchema } from "graphql"; 3 | import { makeSchema } from "nexus"; 4 | import { nexusShield, allow } from "nexus-shield"; 5 | import * as types from "./nexus/_types"; 6 | 7 | export default makeSchema({ 8 | plugins: [ 9 | nexusShield({ 10 | defaultError: new Error("Unauthorized"), 11 | defaultRule: allow, 12 | }), 13 | ], 14 | types, 15 | shouldGenerateArtifacts: process.env.NODE_ENV === "development", 16 | outputs: { 17 | schema: resolve(process.cwd(), "generated/schema.graphql"), 18 | typegen: resolve(process.cwd(), "generated/nexus-types.ts"), 19 | }, 20 | contextType: { 21 | module: resolve(process.cwd(), "server/context.ts"), 22 | export: "Context", 23 | }, 24 | }) as unknown as GraphQLSchema; 25 | -------------------------------------------------------------------------------- /server/bootstrap.ts: -------------------------------------------------------------------------------- 1 | import { Server } from "http"; 2 | import { WebSocketServer } from "ws"; 3 | import { useServer } from "graphql-ws/lib/use/ws"; 4 | import { execute, subscribe } from "graphql"; 5 | import { getTokenFromHeaders } from "../utils/jwt"; 6 | import schema from "./schema"; 7 | import { pubsub } from "./pubsub"; 8 | import { contextFactory } from "./context"; 9 | 10 | export async function initialize(server: Server) { 11 | console.log("Initializing..."); 12 | 13 | // Configure graphql-ws 14 | const wsServer = new WebSocketServer({ server, path: "/api/graphql" }); 15 | useServer( 16 | { 17 | schema, 18 | execute, 19 | subscribe, 20 | context: ({ extra }) => contextFactory(getTokenFromHeaders(extra.request.headers as { cookie?: string })), 21 | }, 22 | wsServer, 23 | ); 24 | 25 | // Ping every 3000ms 26 | setInterval(() => pubsub.publish("ping", { ping: `Ping ${Math.random()}` }), 3000); 27 | } 28 | -------------------------------------------------------------------------------- /server/entries/node.ts: -------------------------------------------------------------------------------- 1 | import "#polyfill"; 2 | import { Server as HttpServer } from "http"; 3 | import { Server as HttpsServer } from "https"; 4 | import destr from "destr"; 5 | import { handle } from "@nuxt/nitro/dist/runtime/server"; 6 | import { initialize } from "../bootstrap"; 7 | import { baseURL } from "#paths"; 8 | 9 | const cert = process.env.NITRO_SSL_CERT; 10 | const key = process.env.NITRO_SSL_KEY; 11 | 12 | const server = cert && key ? new HttpsServer({ key, cert }, handle) : new HttpServer(handle); 13 | 14 | const port = (destr(process.env.NUXT_PORT || process.env.PORT) || 3000) as number; 15 | const hostname = process.env.NUXT_HOST || process.env.HOST || "localhost"; 16 | 17 | initialize(server).then(() => { 18 | server 19 | .listen(port, hostname, async () => { 20 | const protocol = cert && key ? "https" : "http"; 21 | console.log(`Listening on ${protocol}://${hostname}:${port}${baseURL()}`); 22 | }) 23 | .on("error", async (err) => { 24 | console.error(err); 25 | process.exit(1); 26 | }); 27 | }); 28 | 29 | export default {}; 30 | -------------------------------------------------------------------------------- /generated/schema.ts: -------------------------------------------------------------------------------- 1 | export type Maybe = T | null; 2 | export type InputMaybe = Maybe; 3 | export type Exact = { [K in keyof T]: T[K] }; 4 | export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; 5 | export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; 6 | /** All built-in and custom scalars, mapped to their actual values */ 7 | export type Scalars = { 8 | ID: string; 9 | String: string; 10 | Boolean: boolean; 11 | Int: number; 12 | Float: number; 13 | }; 14 | 15 | export type Query = { 16 | __typename?: "Query"; 17 | hello: Scalars["String"]; 18 | }; 19 | 20 | export type Subscription = { 21 | __typename?: "Subscription"; 22 | ping?: Maybe; 23 | }; 24 | 25 | export type User = { 26 | __typename?: "User"; 27 | email: Scalars["String"]; 28 | id: Scalars["Int"]; 29 | role: UserRole; 30 | }; 31 | 32 | export enum UserRole { 33 | Admin = "ADMIN", 34 | Editor = "EDITOR", 35 | Guest = "GUEST", 36 | Unverified = "UNVERIFIED", 37 | } 38 | -------------------------------------------------------------------------------- /server/api/graphql.ts: -------------------------------------------------------------------------------- 1 | import { defineHandle, useBody, useQuery } from "h3"; 2 | import { getGraphQLParameters, processRequest, renderGraphiQL, sendResult, shouldRenderGraphiQL } from "graphql-helix"; 3 | import type { Context } from "../context"; 4 | import { contextFactory } from "../context"; 5 | import { getTokenFromHeaders } from "../../utils/jwt"; 6 | import schema from "../schema"; 7 | import config from "#config"; 8 | 9 | export default defineHandle(async (req, res) => { 10 | // Construct GraphQL request 11 | const request = { 12 | body: req.method !== "GET" && (await useBody(req)), 13 | headers: req.headers, 14 | method: req.method || "GET", 15 | query: useQuery(req), 16 | }; 17 | 18 | // Render GraphiQL in development only 19 | if (process.env.NODE_ENV === "development" && shouldRenderGraphiQL(request)) { 20 | const subscriptionsEndpoint = config.graphqlApiURL.replace("http", "ws"); 21 | return renderGraphiQL({ endpoint: "/api/graphql", subscriptionsEndpoint }); 22 | } 23 | 24 | // Process GraphQL request and send result 25 | const { operationName, query, variables } = getGraphQLParameters(request); 26 | const result = await processRequest({ 27 | operationName, 28 | query, 29 | variables, 30 | request, 31 | schema, 32 | contextFactory: ({ request }) => contextFactory(getTokenFromHeaders(request.headers as { cookie?: string })), 33 | }); 34 | sendResult(result, res); 35 | }); 36 | -------------------------------------------------------------------------------- /composables/auth.ts: -------------------------------------------------------------------------------- 1 | import { UserRole } from "@prisma/client"; 2 | import type { AuthState } from "~/utils/jwt"; 3 | 4 | declare global { 5 | interface LoginFormData { 6 | email: string; 7 | password: string; 8 | } 9 | } 10 | 11 | export const useAuth = () => { 12 | // Current authentication state (initialized in plugins/auth.server.ts) 13 | const auth = useState("auth", () => ({ user: null })); 14 | 15 | // Authorization rules 16 | const isAuthenticated = computed(() => !!auth.value.user?.id); 17 | const hasUserRole = (role: UserRole) => ["ADMIN", role].includes(auth.value.user?.role || ""); 18 | 19 | // Authentication helpers 20 | const login = async (credentials: LoginFormData) => { 21 | const result = await $fetch("/api/login", { method: "POST", body: credentials }); 22 | auth.value = result; 23 | }; 24 | const logout = async () => { 25 | const result = await $fetch("/api/logout", { method: "POST" }); 26 | auth.value = result; 27 | }; 28 | 29 | // FormKit schema for the login form 30 | const loginFormSchema = [ 31 | { 32 | $formkit: "text", 33 | name: "email", 34 | label: "Email", 35 | validation: "required|email", 36 | }, 37 | { 38 | $formkit: "password", 39 | name: "password", 40 | label: "Password", 41 | validation: "required", 42 | }, 43 | ]; 44 | 45 | return { auth, isAuthenticated, hasUserRole, login, logout, loginFormSchema }; 46 | }; 47 | -------------------------------------------------------------------------------- /utils/jwt.ts: -------------------------------------------------------------------------------- 1 | import type { ServerResponse } from "http"; 2 | import type { User, UserRole } from "@prisma/client"; 3 | import type { CookieSerializeOptions } from "cookie-es"; 4 | import { parse } from "cookie-es"; 5 | import { setCookie } from "h3"; 6 | import type { SignOptions } from "jsonwebtoken"; 7 | import jwt from "jsonwebtoken"; 8 | 9 | // Type safe authentication state validation 10 | export interface AuthState { 11 | user: null | { 12 | id: number; 13 | role: UserRole; 14 | }; 15 | } 16 | 17 | export const validateAuthState = (authState: AuthState): AuthState => ({ 18 | user: authState?.user ? (({ id, role }) => ({ id, role }))(authState.user) : null, 19 | }); 20 | 21 | // Decode and validate authentication state payload from (string) token 22 | const jwtSecretKey = process.env.JWT_SECRET_KEY || "jwtsecretkey"; 23 | 24 | export const decodeJwt = (token: string): AuthState => { 25 | try { 26 | const authState = jwt.verify(token, jwtSecretKey) as AuthState; 27 | return validateAuthState(authState); 28 | } catch (error) { 29 | return { user: null }; 30 | } 31 | }; 32 | 33 | // Encode authentication state as JWT cookie in server response 34 | export const jwtCookieName = process.env.JWT_COOKIE_NAME || "jwt"; 35 | const jwtSignOptions: SignOptions = { expiresIn: "2h" }; 36 | const jwtCookieOptions: CookieSerializeOptions = { path: "/", httpOnly: true }; 37 | 38 | export const setAuthState = (user: User | null, res: ServerResponse): AuthState => { 39 | const authState = validateAuthState({ user }); 40 | setCookie(res, jwtCookieName, jwt.sign(authState, jwtSecretKey, jwtSignOptions), jwtCookieOptions); 41 | return authState; 42 | }; 43 | 44 | // Extract JWT token from request.headers 45 | export const getTokenFromHeaders = (headers: { cookie?: string }): string => { 46 | const cookies: Record = parse(headers.cookie || ""); 47 | return cookies[jwtCookieName] || ""; 48 | }; 49 | -------------------------------------------------------------------------------- /plugins/urql.ts: -------------------------------------------------------------------------------- 1 | import urql, { cacheExchange, dedupExchange, fetchExchange, ssrExchange, subscriptionExchange } from "@urql/vue"; 2 | import { createClient as createWSClient } from "graphql-ws"; 3 | import { devtoolsExchange } from "@urql/devtools"; 4 | import { WebSocket } from "ws"; 5 | import { defineNuxtPlugin, useRuntimeConfig } from "#app"; 6 | 7 | export default defineNuxtPlugin((nuxtApp) => { 8 | const { graphqlApiURL } = useRuntimeConfig(); 9 | 10 | // Create SSR exchange 11 | const ssr = ssrExchange({ 12 | isClient: process.client, 13 | }); 14 | 15 | // Extract SSR payload once app is rendered on the server 16 | if (process.server) { 17 | nuxtApp.hook("app:rendered", () => { 18 | nuxtApp.payload?.data && (nuxtApp.payload.data.urql = ssr.extractData()); 19 | }); 20 | } 21 | 22 | // Restore SSR payload once app is created on the client 23 | if (process.client) { 24 | nuxtApp.hook("app:created", () => { 25 | nuxtApp.payload?.data && ssr.restoreData(nuxtApp.payload.data.urql); 26 | }); 27 | } 28 | 29 | // Subscription over Websocket exchange 30 | const wsClient = createWSClient({ 31 | url: graphqlApiURL.replace("http", "ws"), 32 | webSocketImpl: process.server && WebSocket, 33 | }); 34 | const wsExchange = subscriptionExchange({ 35 | forwardSubscription(operation) { 36 | return { 37 | subscribe: (sink) => { 38 | const dispose = wsClient.subscribe(operation, sink); 39 | return { 40 | unsubscribe: dispose, 41 | }; 42 | }, 43 | }; 44 | }, 45 | }); 46 | 47 | // Custom exchanges 48 | const exchanges = [dedupExchange, cacheExchange, ssr, fetchExchange, wsExchange]; 49 | 50 | // Devtools exchange 51 | if (nuxtApp._legacyContext?.isDev) { 52 | exchanges.unshift(devtoolsExchange); 53 | } 54 | 55 | nuxtApp.vueApp.use(urql, { 56 | url: graphqlApiURL, 57 | exchanges, 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /composables/hello.query.ts: -------------------------------------------------------------------------------- 1 | import { DocumentNode } from "graphql"; 2 | import * as Urql from "@urql/vue"; 3 | import * as Types from "../generated/schema"; 4 | export type Maybe = T | null; 5 | export type InputMaybe = Maybe; 6 | export type Exact = { [K in keyof T]: T[K] }; 7 | export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; 8 | export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; 9 | export type Omit = Pick>; 10 | /** All built-in and custom scalars, mapped to their actual values */ 11 | export type Scalars = { 12 | ID: string; 13 | String: string; 14 | Boolean: boolean; 15 | Int: number; 16 | Float: number; 17 | }; 18 | 19 | export type Query = { 20 | __typename?: "Query"; 21 | hello: Scalars["String"]; 22 | }; 23 | 24 | export type Subscription = { 25 | __typename?: "Subscription"; 26 | ping?: Maybe; 27 | }; 28 | 29 | export type User = { 30 | __typename?: "User"; 31 | email: Scalars["String"]; 32 | id: Scalars["Int"]; 33 | role: UserRole; 34 | }; 35 | 36 | export enum UserRole { 37 | Admin = "ADMIN", 38 | Editor = "EDITOR", 39 | Guest = "GUEST", 40 | Unverified = "UNVERIFIED", 41 | } 42 | 43 | export type HelloQueryVariables = Types.Exact<{ [key: string]: never }>; 44 | 45 | export type HelloQuery = { __typename?: "Query"; hello: string }; 46 | 47 | export const HelloDocument = { 48 | kind: "Document", 49 | definitions: [ 50 | { 51 | kind: "OperationDefinition", 52 | operation: "query", 53 | name: { kind: "Name", value: "Hello" }, 54 | selectionSet: { kind: "SelectionSet", selections: [{ kind: "Field", name: { kind: "Name", value: "hello" } }] }, 55 | }, 56 | ], 57 | } as unknown as DocumentNode; 58 | 59 | export function useHelloQuery(options: Omit, "query"> = {}) { 60 | return Urql.useQuery({ query: HelloDocument, ...options }); 61 | } 62 | -------------------------------------------------------------------------------- /composables/ping.subscription.ts: -------------------------------------------------------------------------------- 1 | import { DocumentNode } from "graphql"; 2 | import * as Urql from "@urql/vue"; 3 | import * as Types from "../generated/schema"; 4 | export type Maybe = T | null; 5 | export type InputMaybe = Maybe; 6 | export type Exact = { [K in keyof T]: T[K] }; 7 | export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; 8 | export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; 9 | export type Omit = Pick>; 10 | /** All built-in and custom scalars, mapped to their actual values */ 11 | export type Scalars = { 12 | ID: string; 13 | String: string; 14 | Boolean: boolean; 15 | Int: number; 16 | Float: number; 17 | }; 18 | 19 | export type Query = { 20 | __typename?: "Query"; 21 | hello: Scalars["String"]; 22 | }; 23 | 24 | export type Subscription = { 25 | __typename?: "Subscription"; 26 | ping?: Maybe; 27 | }; 28 | 29 | export type User = { 30 | __typename?: "User"; 31 | email: Scalars["String"]; 32 | id: Scalars["Int"]; 33 | role: UserRole; 34 | }; 35 | 36 | export enum UserRole { 37 | Admin = "ADMIN", 38 | Editor = "EDITOR", 39 | Guest = "GUEST", 40 | Unverified = "UNVERIFIED", 41 | } 42 | 43 | export type PingSubscriptionVariables = Types.Exact<{ [key: string]: never }>; 44 | 45 | export type PingSubscription = { __typename?: "Subscription"; ping?: string | null }; 46 | 47 | export const PingDocument = { 48 | kind: "Document", 49 | definitions: [ 50 | { 51 | kind: "OperationDefinition", 52 | operation: "subscription", 53 | name: { kind: "Name", value: "Ping" }, 54 | selectionSet: { kind: "SelectionSet", selections: [{ kind: "Field", name: { kind: "Name", value: "ping" } }] }, 55 | }, 56 | ], 57 | } as unknown as DocumentNode; 58 | 59 | export function usePingSubscription( 60 | options: Omit, "query"> = {}, 61 | handler?: Urql.SubscriptionHandlerArg, 62 | ) { 63 | return Urql.useSubscription( 64 | { query: PingDocument, ...options }, 65 | handler, 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@lewebsimple/nuxt3-fullstack", 3 | "description": "Exploring Nuxt3 as a full stack solution", 4 | "version": "0.8.0", 5 | "license": "MIT", 6 | "private": true, 7 | "scripts": { 8 | "build": "nuxi build", 9 | "clean": "rm -rf .nuxt .output node_modules yarn.lock", 10 | "codegen": "NODE_NO_WARNINGS=1 graphql-codegen", 11 | "dev": "nuxi dev", 12 | "lint": "eslint --fix --ignore-path .gitignore .", 13 | "postinstall": "prisma generate", 14 | "release": "release-it", 15 | "start": "node .output/server/index.mjs" 16 | }, 17 | "prisma": { 18 | "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts" 19 | }, 20 | "devDependencies": { 21 | "@formkit/nuxt": "^1.0.0-beta.6", 22 | "@formkit/tailwindcss": "^1.0.0-beta.6", 23 | "@formkit/vue": "^1.0.0-beta.6", 24 | "@graphql-codegen/cli": "^2.6.2", 25 | "@graphql-codegen/near-operation-file-preset": "^2.2.9", 26 | "@graphql-codegen/typescript": "^2.4.8", 27 | "@graphql-codegen/typescript-operations": "^2.3.5", 28 | "@graphql-codegen/typescript-vue-urql": "^2.2.9", 29 | "@lewebsimple/eslint-config-vue": "^0.5.4", 30 | "@nuxtjs/tailwindcss": "^5.0.2", 31 | "@prisma/client": "^3.11.1", 32 | "@tailwindcss/forms": "^0.5.0", 33 | "@tailwindcss/line-clamp": "^0.3.1", 34 | "@tailwindcss/typography": "^0.5.2", 35 | "@types/bcrypt": "^5.0.0", 36 | "@types/jsonwebtoken": "^8.5.8", 37 | "@types/ws": "^8.5.3", 38 | "@urql/devtools": "^2.0.3", 39 | "@urql/vue": "^0.6.1", 40 | "bcrypt": "^5.0.1", 41 | "dotenv": "^16.0.0", 42 | "eslint": "^8.12.0", 43 | "graphql": "^16.3.0", 44 | "graphql-helix": "^1.12.0", 45 | "graphql-subscriptions": "^2.0.0", 46 | "graphql-ws": "^5.6.4", 47 | "jsonwebtoken": "^8.5.1", 48 | "nexus": "^1.3.0", 49 | "nexus-prisma": "^0.35.0", 50 | "nexus-shield": "^2.1.0", 51 | "nuxt3": "latest", 52 | "prisma": "^3.11.1", 53 | "release-it": "^14.13.1", 54 | "ts-node": "^10.7.0", 55 | "ws": "^8.5.0" 56 | }, 57 | "eslintConfig": { 58 | "extends": "@lewebsimple/eslint-config-vue" 59 | }, 60 | "release-it": { 61 | "github": { 62 | "release": true 63 | }, 64 | "npm": { 65 | "publish": false 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /generated/nexus-types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file was generated by Nexus Schema 3 | * Do not make changes to this file directly 4 | */ 5 | 6 | import type { FieldShieldResolver, ObjectTypeShieldResolver } from "nexus-shield"; 7 | import type { Context } from "./../server/context"; 8 | 9 | declare global { 10 | interface NexusGen extends NexusGenTypes {} 11 | } 12 | 13 | export interface NexusGenInputs {} 14 | 15 | export interface NexusGenEnums { 16 | UserRole: "ADMIN" | "EDITOR" | "GUEST" | "UNVERIFIED"; 17 | } 18 | 19 | export interface NexusGenScalars { 20 | String: string; 21 | Int: number; 22 | Float: number; 23 | Boolean: boolean; 24 | ID: string; 25 | } 26 | 27 | export interface NexusGenObjects { 28 | Query: {}; 29 | Subscription: {}; 30 | User: { 31 | // root type 32 | email: string; // String! 33 | id: number; // Int! 34 | role: NexusGenEnums["UserRole"]; // UserRole! 35 | }; 36 | } 37 | 38 | export interface NexusGenInterfaces {} 39 | 40 | export interface NexusGenUnions {} 41 | 42 | export type NexusGenRootTypes = NexusGenObjects; 43 | 44 | export type NexusGenAllTypes = NexusGenRootTypes & NexusGenScalars & NexusGenEnums; 45 | 46 | export interface NexusGenFieldTypes { 47 | Query: { 48 | // field return type 49 | hello: string; // String! 50 | }; 51 | Subscription: { 52 | // field return type 53 | ping: string | null; // String 54 | }; 55 | User: { 56 | // field return type 57 | email: string; // String! 58 | id: number; // Int! 59 | role: NexusGenEnums["UserRole"]; // UserRole! 60 | }; 61 | } 62 | 63 | export interface NexusGenFieldTypeNames { 64 | Query: { 65 | // field return type name 66 | hello: "String"; 67 | }; 68 | Subscription: { 69 | // field return type name 70 | ping: "String"; 71 | }; 72 | User: { 73 | // field return type name 74 | email: "String"; 75 | id: "Int"; 76 | role: "UserRole"; 77 | }; 78 | } 79 | 80 | export interface NexusGenArgTypes {} 81 | 82 | export interface NexusGenAbstractTypeMembers {} 83 | 84 | export interface NexusGenTypeInterfaces {} 85 | 86 | export type NexusGenObjectNames = keyof NexusGenObjects; 87 | 88 | export type NexusGenInputNames = never; 89 | 90 | export type NexusGenEnumNames = keyof NexusGenEnums; 91 | 92 | export type NexusGenInterfaceNames = never; 93 | 94 | export type NexusGenScalarNames = keyof NexusGenScalars; 95 | 96 | export type NexusGenUnionNames = never; 97 | 98 | export type NexusGenObjectsUsingAbstractStrategyIsTypeOf = never; 99 | 100 | export type NexusGenAbstractsUsingStrategyResolveType = never; 101 | 102 | export type NexusGenFeaturesConfig = { 103 | abstractTypeStrategies: { 104 | isTypeOf: false; 105 | resolveType: true; 106 | __typename: false; 107 | }; 108 | }; 109 | 110 | export interface NexusGenTypes { 111 | context: Context; 112 | inputTypes: NexusGenInputs; 113 | rootTypes: NexusGenRootTypes; 114 | inputTypeShapes: NexusGenInputs & NexusGenEnums & NexusGenScalars; 115 | argTypes: NexusGenArgTypes; 116 | fieldTypes: NexusGenFieldTypes; 117 | fieldTypeNames: NexusGenFieldTypeNames; 118 | allTypes: NexusGenAllTypes; 119 | typeInterfaces: NexusGenTypeInterfaces; 120 | objectNames: NexusGenObjectNames; 121 | inputNames: NexusGenInputNames; 122 | enumNames: NexusGenEnumNames; 123 | interfaceNames: NexusGenInterfaceNames; 124 | scalarNames: NexusGenScalarNames; 125 | unionNames: NexusGenUnionNames; 126 | allInputTypes: NexusGenTypes["inputNames"] | NexusGenTypes["enumNames"] | NexusGenTypes["scalarNames"]; 127 | allOutputTypes: 128 | | NexusGenTypes["objectNames"] 129 | | NexusGenTypes["enumNames"] 130 | | NexusGenTypes["unionNames"] 131 | | NexusGenTypes["interfaceNames"] 132 | | NexusGenTypes["scalarNames"]; 133 | allNamedTypes: NexusGenTypes["allInputTypes"] | NexusGenTypes["allOutputTypes"]; 134 | abstractTypes: NexusGenTypes["interfaceNames"] | NexusGenTypes["unionNames"]; 135 | abstractTypeMembers: NexusGenAbstractTypeMembers; 136 | objectsUsingAbstractStrategyIsTypeOf: NexusGenObjectsUsingAbstractStrategyIsTypeOf; 137 | abstractsUsingStrategyResolveType: NexusGenAbstractsUsingStrategyResolveType; 138 | features: NexusGenFeaturesConfig; 139 | } 140 | 141 | declare global { 142 | interface NexusGenPluginTypeConfig { 143 | /** 144 | * Default authorization rule to execute on all fields of this object 145 | */ 146 | shield?: ObjectTypeShieldResolver; 147 | } 148 | interface NexusGenPluginInputTypeConfig {} 149 | interface NexusGenPluginFieldConfig { 150 | /** 151 | * Authorization rule to execute for this field 152 | */ 153 | shield?: FieldShieldResolver; 154 | } 155 | interface NexusGenPluginInputFieldConfig {} 156 | interface NexusGenPluginSchemaConfig {} 157 | interface NexusGenPluginArgConfig {} 158 | } 159 | --------------------------------------------------------------------------------