commit aac376bb569cdfde24bb43c18d162134c032ca1c Author: Flycro Date: Tue Nov 7 19:02:50 2023 +0100 Init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a7f73a --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Nuxt dev/build outputs +.output +.data +.nuxt +.nitro +.cache +dist + +# Node dependencies +node_modules + +# Logs +logs +*.log + +# Misc +.DS_Store +.fleet +.idea + +# Local env files +.env +.env.* +!.env.example diff --git a/README.md b/README.md new file mode 100644 index 0000000..f5db2a2 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# Nuxt 3 Minimal Starter + +Look at the [Nuxt 3 documentation](https://nuxt.com/docs/getting-started/introduction) to learn more. + +## Setup + +Make sure to install the dependencies: + +```bash +# npm +npm install + +# pnpm +pnpm install + +# yarn +yarn install + +# bun +bun install +``` + +## Development Server + +Start the development server on `http://localhost:3000`: + +```bash +# npm +npm run dev + +# pnpm +pnpm run dev + +# yarn +yarn dev + +# bun +bun run dev +``` + +## Production + +Build the application for production: + +```bash +# npm +npm run build + +# pnpm +pnpm run build + +# yarn +yarn build + +# bun +bun run build +``` + +Locally preview production build: + +```bash +# npm +npm run preview + +# pnpm +pnpm run preview + +# yarn +yarn preview + +# bun +bun run preview +``` + +Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information. diff --git a/app.config.ts b/app.config.ts new file mode 100644 index 0000000..c2c1e36 --- /dev/null +++ b/app.config.ts @@ -0,0 +1,6 @@ +export default defineAppConfig({ + ui: { + primary: 'sky', + gray: 'cool', + }, +}) diff --git a/app.vue b/app.vue new file mode 100644 index 0000000..3fc7df1 --- /dev/null +++ b/app.vue @@ -0,0 +1,7 @@ + diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..116d51d Binary files /dev/null and b/bun.lockb differ diff --git a/components/Header.vue b/components/Header.vue new file mode 100644 index 0000000..1aee482 --- /dev/null +++ b/components/Header.vue @@ -0,0 +1,38 @@ + + + diff --git a/components/Logo.vue b/components/Logo.vue new file mode 100644 index 0000000..a71bf1d --- /dev/null +++ b/components/Logo.vue @@ -0,0 +1,5 @@ + diff --git a/components/Navigation.vue b/components/Navigation.vue new file mode 100644 index 0000000..52df22a --- /dev/null +++ b/components/Navigation.vue @@ -0,0 +1,31 @@ + + + diff --git a/composables/useAuth.ts b/composables/useAuth.ts new file mode 100644 index 0000000..2d16ab8 --- /dev/null +++ b/composables/useAuth.ts @@ -0,0 +1,112 @@ +export interface User { + name: string + email?: string +} + +export interface LoginCredentials { + email: string + password: string + remember?: boolean +} + +export interface RegisterCredentials { + name: string + email: string + password: string + password_confirmation: string +} + +export interface ResetPasswordCredentials { + email: string + password: string + password_confirmation: string + token: string +} + +// Value is initialized in: ~/plugins/auth.ts +export function useUser() { + return useState('user', () => undefined) +} + +export function useAuth() { + const router = useRouter() + + const user = useUser() + const isLoggedIn = computed(() => !!user.value) + + async function refresh() { + try { + user.value = await fetchCurrentUser() + } + catch { + user.value = null + } + } + + async function login(credentials: LoginCredentials) { + if (isLoggedIn.value) { return } + + await $larafetch('/login', { method: 'post', body: credentials }) + await refresh() + } + + async function register(credentials: RegisterCredentials) { + await $larafetch('/register', { method: 'post', body: credentials }) + await refresh() + } + + async function resendEmailVerification() { + return await $larafetch<{ status: string }>( + '/email/verification-notification', + { + method: 'post', + }, + ) + } + + async function logout() { + if (!isLoggedIn.value) { return } + + await $larafetch('/logout', { method: 'post' }) + user.value = null + + await router.push('/login') + } + + async function forgotPassword(email: string) { + return await $larafetch<{ status: string }>('/forgot-password', { + method: 'post', + body: { email }, + }) + } + + async function resetPassword(credentials: ResetPasswordCredentials) { + return await $larafetch<{ status: string }>('/reset-password', { + method: 'post', + body: credentials, + }) + } + + return { + user, + isLoggedIn, + login, + register, + resendEmailVerification, + logout, + forgotPassword, + resetPassword, + refresh, + } +} + +export async function fetchCurrentUser() { + try { + return await $larafetch('/api/user') + } + catch (error: any) { + if ([401, 419].includes(error?.response?.status)) { return null } + if (error?.response?.status === undefined) { return null } + throw error + } +} diff --git a/composables/useLarafetch.ts b/composables/useLarafetch.ts new file mode 100644 index 0000000..332651c --- /dev/null +++ b/composables/useLarafetch.ts @@ -0,0 +1,25 @@ +import type { UseFetchOptions } from 'nuxt/app' + +export function useLarafetch( + url: string | (() => string), + options: UseFetchOptions = {}, +) { + return useFetch(url, { + $fetch: $larafetch, + async onResponseError({ response }) { + const status = response.status + if ([500].includes(status)) { + console.error('[Laravel Error]', response.statusText, response._data) + } + + if ([401, 419].includes(status)) { + navigateTo('/login') + } + + if ([409].includes(status)) { + navigateTo('/verify-email') + } + }, + ...options, + }) +} diff --git a/composables/useSubmit.ts b/composables/useSubmit.ts new file mode 100644 index 0000000..b1690cb --- /dev/null +++ b/composables/useSubmit.ts @@ -0,0 +1,61 @@ +import type { FormError } from '@nuxt/ui/dist/runtime/types' + +export interface UseSubmitOptions { + onSuccess?: (result: any) => any + onError?: (error: Error) => any +} + +export function useSubmit( + fetchable: () => Promise, + options: UseSubmitOptions = {}, +) { + const validationErrors = ref([]) + const error = ref(null) + const inProgress = ref(false) + const succeeded = ref(null) + + async function submit() { + validationErrors.value = [] + error.value = null + inProgress.value = true + succeeded.value = null + + try { + const data = await fetchable() + succeeded.value = true + options?.onSuccess?.(data) + return data + } + catch (e: any) { + error.value = e + succeeded.value = false + options?.onError?.(e) + if (e.data?.errors) { + const errorsArray: FormError[] = [] + for (const path in e.data.errors) { + const messages = e.data.errors[path] + messages.forEach((message: string) => { + errorsArray.push({ path, message }) + }) + } + validationErrors.value = errorsArray + } + else { + validationErrors.value = [] + } + + if (e.response?.status !== 422) { throw e } + } + finally { + inProgress.value = false + } + } + + return { + submit, + inProgress, + succeeded, + validationErrors, + error, + } +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..4d12e07 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,17 @@ +import antfu from '@antfu/eslint-config' +import { FlatCompat } from '@eslint/eslintrc' + +const compat = new FlatCompat() + +export default antfu({ + rules: { + 'curly': ['error', 'all'], + 'node/prefer-global/process': ['error', 'always'], + }, +}, ...compat.config({ + extends: ['plugin:tailwindcss/recommended'], + rules: { + 'tailwindcss/no-custom-classname': 'off', + 'tailwindcss/migration-from-tailwind-2': 'off', + }, +})) diff --git a/layouts/app.vue b/layouts/app.vue new file mode 100644 index 0000000..f9cdf69 --- /dev/null +++ b/layouts/app.vue @@ -0,0 +1,17 @@ + diff --git a/layouts/default.vue b/layouts/default.vue new file mode 100644 index 0000000..dbf816c --- /dev/null +++ b/layouts/default.vue @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/middleware/auth.ts b/middleware/auth.ts new file mode 100644 index 0000000..767acb0 --- /dev/null +++ b/middleware/auth.ts @@ -0,0 +1,4 @@ +export default defineNuxtRouteMiddleware(async () => { + const user = useUser(); + if (!user.value) return navigateTo("/login", { replace: true }); + }); \ No newline at end of file diff --git a/middleware/guest.ts b/middleware/guest.ts new file mode 100644 index 0000000..0e1c88c --- /dev/null +++ b/middleware/guest.ts @@ -0,0 +1,4 @@ +export default defineNuxtRouteMiddleware(async () => { + const user = useUser(); + if (user.value) return navigateTo("/", { replace: true }); + }); \ No newline at end of file diff --git a/middleware/unverified.ts b/middleware/unverified.ts new file mode 100644 index 0000000..4a5dc6f --- /dev/null +++ b/middleware/unverified.ts @@ -0,0 +1,9 @@ +export default defineNuxtRouteMiddleware(() => { + const user = useUser(); + + if (!user.value) return navigateTo("/login"); + + // @ts-ignore + if (user.value.email_verified_at || user.value.is_verified) + return navigateTo("/"); + }); \ No newline at end of file diff --git a/middleware/verified.ts b/middleware/verified.ts new file mode 100644 index 0000000..ec62c45 --- /dev/null +++ b/middleware/verified.ts @@ -0,0 +1,9 @@ +export default defineNuxtRouteMiddleware(() => { + const user = useUser(); + + if (!user.value) return navigateTo("/login"); + + // @ts-ignore + if (!(user.value.email_verified_at || user.value.is_verified)) + return navigateTo("/verify-email"); + }); \ No newline at end of file diff --git a/nuxt.config.ts b/nuxt.config.ts new file mode 100644 index 0000000..04fcdfe --- /dev/null +++ b/nuxt.config.ts @@ -0,0 +1,15 @@ +// https://nuxt.com/docs/api/configuration/nuxt-config +export default defineNuxtConfig({ + extends: ['@nuxt/ui-pro'], + modules: ['@nuxt/ui'], + runtimeConfig: { + public: { + backendUrl: 'http://localhost', + frontendUrl: 'http://localhost:3000', + }, + }, + imports: { + dirs: ['./utils'], + }, + devtools: { enabled: true }, +}) diff --git a/package.json b/package.json new file mode 100644 index 0000000..ba868be --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "nuxt-app", + "type": "module", + "private": true, + "scripts": { + "build": "nuxt build", + "dev": "nuxt dev", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "generate": "nuxt generate", + "preview": "nuxt preview", + "postinstall": "nuxt prepare" + }, + "devDependencies": { + "@antfu/eslint-config": "^1.1.0", + "@iconify-json/heroicons": "^1.1.13", + "@iconify-json/logos": "^1.1.37", + "@iconify-json/simple-icons": "^1.1.76", + "@nuxt/devtools": "latest", + "@nuxt/ui-pro": "^0.4.2", + "eslint": "^8.53.0", + "eslint-plugin-tailwindcss": "^3.13.0", + "nuxt": "^3.8.1", + "typescript": "^5.2.2", + "vue": "^3.3.8", + "vue-router": "^4.2.5" + } +} diff --git a/pages/forgot-password/index.vue b/pages/forgot-password/index.vue new file mode 100644 index 0000000..b298f6c --- /dev/null +++ b/pages/forgot-password/index.vue @@ -0,0 +1,72 @@ + + + diff --git a/pages/index.vue b/pages/index.vue new file mode 100644 index 0000000..98d7390 --- /dev/null +++ b/pages/index.vue @@ -0,0 +1,11 @@ + + + + + diff --git a/pages/login/index.vue b/pages/login/index.vue new file mode 100644 index 0000000..fa7a0bc --- /dev/null +++ b/pages/login/index.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/pages/password-reset/[token].vue b/pages/password-reset/[token].vue new file mode 100644 index 0000000..0c15b80 --- /dev/null +++ b/pages/password-reset/[token].vue @@ -0,0 +1,87 @@ + + + + + diff --git a/plugins/auth.ts b/plugins/auth.ts new file mode 100644 index 0000000..bd270f9 --- /dev/null +++ b/plugins/auth.ts @@ -0,0 +1,10 @@ +import { fetchCurrentUser, useUser } from '~/composables/useAuth' + +export default defineNuxtPlugin(async () => { + const user = useUser() + + // Skip if already initialized on server + if (user.value !== undefined) { return } + + user.value = await fetchCurrentUser() +}) diff --git a/plugins/error-handler.ts b/plugins/error-handler.ts new file mode 100644 index 0000000..6d52318 --- /dev/null +++ b/plugins/error-handler.ts @@ -0,0 +1,17 @@ +import { FetchError } from 'ofetch' + +export default defineNuxtPlugin(async (nuxtApp) => { + nuxtApp.hook('vue:error', (error) => { + if (!(error instanceof FetchError)) { throw error } + + const status = error.response?.status ?? -1 + + if ([401, 419].includes(status)) { + navigateTo('/login') + } + + if ([409].includes(status)) { + navigateTo('/verify-email') + } + }) +}) diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..18993ad Binary files /dev/null and b/public/favicon.ico differ diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..b9ed69c --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../.nuxt/tsconfig.server.json" +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a746f2a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,4 @@ +{ + // https://nuxt.com/docs/guide/concepts/typescript + "extends": "./.nuxt/tsconfig.json" +} diff --git a/utils/$larafetch.ts b/utils/$larafetch.ts new file mode 100644 index 0000000..13cd2ba --- /dev/null +++ b/utils/$larafetch.ts @@ -0,0 +1,67 @@ +import { $fetch } from 'ofetch' +import { parseCookies } from 'h3' + +const CSRF_COOKIE = 'XSRF-TOKEN' +const CSRF_HEADER = 'X-XSRF-TOKEN' + +export const $larafetch = $fetch.create({ + credentials: 'include', + async onRequest({ options }) { + const { backendUrl, frontendUrl } = useRuntimeConfig().public + const event = process.nitro ? useEvent() : null + let token = event + ? parseCookies(event)[CSRF_COOKIE] + : useCookie(CSRF_COOKIE).value + + // on client initiate a csrf request and get it from the cookie set by laravel + if ( + process.client + && ['post', 'delete', 'put', 'patch'].includes( + options?.method?.toLowerCase() ?? '', + ) + ) { + token = await initCsrf() + } + + let headers: any = { + accept: 'application/json', + ...options?.headers, + ...(token && { [CSRF_HEADER]: token }), + } + + if (process.server) { + const cookieString = event + ? event.headers.get('cookie') + : useRequestHeaders(['cookie']).cookie + + headers = { + ...headers, + ...(cookieString && { cookie: cookieString }), + referer: frontendUrl, + } + } + + options.headers = headers + options.baseURL = backendUrl + }, + async onResponseError({ response }) { + const status = response.status + if ([500].includes(status)) { + console.error('[Laravel Error]', response.statusText, response._data) + } + }, +}) + +async function initCsrf() { + const { backendUrl } = useRuntimeConfig().public + const existingToken = useCookie(CSRF_COOKIE).value + + if (existingToken) { return existingToken } + + await $fetch('/sanctum/csrf-cookie', { + baseURL: backendUrl, + credentials: 'include', + }) + + return useCookie(CSRF_COOKIE).value +}