This commit is contained in:
2023-11-16 22:57:56 +01:00
commit 237d969e5c
146 changed files with 23818 additions and 0 deletions

7
apps/web/.env.example Normal file
View File

@@ -0,0 +1,7 @@
PUSHER_APP_BROADCASTER=pusher
PUSHER_APP_KEY=app-key
PUSHER_APP_HOST=localhost
PUSHER_APP_PORT=6001
PUSHER_APP_TLS=false
PUSHER_APP_ENCRYPTED=true
PUSHER_APP_DISABLE_STATS=true

24
apps/web/.gitignore vendored Normal file
View File

@@ -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

3
apps/web/.nuxtignore Normal file
View File

@@ -0,0 +1,3 @@
# Disable Laravel Echo
composables/useEcho.ts
plugins/echo.client.ts

75
apps/web/README.md Normal file
View File

@@ -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.

9
apps/web/app.config.ts Normal file
View File

@@ -0,0 +1,9 @@
export default defineAppConfig({
ui: {
primary: 'sky',
gray: 'cool',
container: {
constrained: 'max-w-full',
},
},
})

7
apps/web/app.vue Normal file
View File

@@ -0,0 +1,7 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<UNotifications />
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
const links = [{
label: 'Documentation',
icon: 'i-heroicons-book-open',
to: 'https://ui.nuxt.com/getting-started',
}, {
label: 'Pro',
icon: 'i-heroicons-square-3-stack-3d',
to: 'https://ui.nuxt.com/pro',
}, {
label: 'Releases',
icon: 'i-heroicons-rocket-launch',
to: 'https://github.com/nuxt/ui/releases',
target: '_blank',
}]
</script>
<template>
<UHeader :links="links">
<template #logo>
<Logo class="h-6 w-auto" />
</template>
<template #right>
<UColorModeButton />
<UserDropdown />
</template>
<template #panel>
<UNavigationTree :links="links" />
</template>
</UHeader>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<div>
<span class="text-primary">Nuxt</span> Breeze
</div>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
const links = [
{
label: 'Company',
icon: 'i-heroicons-building-office-2',
children: [
{
label: 'Overview',
to: '/login',
icon: 'i-heroicons-eye',
},
{
label: 'Add Company',
to: '/login',
icon: 'i-heroicons-plus-circle',
},
],
},
{
label: 'People',
to: '/login',
icon: 'i-heroicons-user-group',
},
]
</script>
<template>
<UContainer>
<UNavigationTree :links="links" />
</UContainer>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
const links = [{
label: 'Logout',
to: '/logout',
icon: 'i-heroicons-arrow-left-on-rectangle',
}]
</script>
<template>
<UPopover>
<UButton icon="i-heroicons-user-solid" color="gray" variant="ghost" />
<template #panel>
<div class="p-4">
<UNavigationLinks :links="links" />
</div>
</template>
</UPopover>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,134 @@
export interface User {
id: number
name: string
email: string
email_verified_at: string | null
password?: string
remember_token?: string | null
roles: Role[]
created_at: string | null
updated_at: string | null
}
export interface Role {
name: 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<T = User>() {
return useState<T | undefined | null>('user', () => undefined)
}
export function useAuth<T = User>() {
const router = useRouter()
const user = useUser<T>()
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
const csrf_cookie = useCookie('XSRF-TOKEN')
csrf_cookie.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<T = User>() {
try {
return await $larafetch<T>('/api/user')
}
catch (error: any) {
if ([401, 419].includes(error?.response?.status)) {
return null
}
if (error?.response?.status === undefined) {
return null
}
throw error
}
}

View File

@@ -0,0 +1,4 @@
export default function useEcho() {
const { $echo } = useNuxtApp()
return $echo
}

View File

@@ -0,0 +1,25 @@
import type { UseFetchOptions } from 'nuxt/app'
export function useLarafetch<T>(
url: string | (() => string),
options: UseFetchOptions<T> = {},
) {
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,
})
}

View File

@@ -0,0 +1,11 @@
export function useRoles() {
const user = useUser()
function hasRole(roleName: string) {
return user.value?.roles?.some(role => role.name === roleName) ?? false
}
return {
hasRole,
}
}

View File

@@ -0,0 +1,63 @@
import type { FormError } from '@nuxt/ui/dist/runtime/types'
export interface UseSubmitOptions {
onSuccess?: (result: any) => any
onError?: (error: Error) => any
}
export function useSubmit<T>(
fetchable: () => Promise<T>,
options: UseSubmitOptions = {},
) {
const validationErrors = ref<FormError[]>([])
const error = ref<Error | null>(null)
const inProgress = ref(false)
const succeeded = ref<boolean | null>(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,
}
}

13
apps/web/error.vue Normal file
View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import type { NuxtError } from '#app'
defineProps<{
error: NuxtError
}>()
</script>
<template>
<NuxtLayout name="auth">
<UPageError :error="error" />
</NuxtLayout>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<div>
<slot />
</div>
</template>

View File

@@ -0,0 +1,17 @@
<template>
<div>
<Header />
<UPage>
<template #left>
<UAside class="lg:static">
<Navigation />
</UAside>
</template>
<UPageBody>
<UContainer>
<slot />
</UContainer>
</UPageBody>
</UPage>
</div>
</template>

View File

@@ -0,0 +1,11 @@
export default defineNuxtRouteMiddleware(async () => {
const { hasRole } = useRoles()
const requiredRole = 'super-admin' // Define the role required for this route
if (!hasRole(requiredRole)) {
return abortNavigation({
message: 'You are not authorized to access this page',
statusCode: 403,
})
}
})

View File

@@ -0,0 +1,6 @@
export default defineNuxtRouteMiddleware(async () => {
const user = useUser()
if (!user.value) {
return navigateTo('/login', { replace: true })
}
})

View File

@@ -0,0 +1,6 @@
export default defineNuxtRouteMiddleware(async () => {
const user = useUser()
if (user.value) {
return navigateTo('/', { replace: true })
}
})

View File

@@ -0,0 +1,11 @@
export default defineNuxtRouteMiddleware(() => {
const user = useUser()
if (!user.value) {
return navigateTo('/login')
}
if (user.value.email_verified_at) {
return navigateTo('/')
}
})

View File

@@ -0,0 +1,11 @@
export default defineNuxtRouteMiddleware(() => {
const user = useUser()
if (!user.value) {
return navigateTo('/login')
}
if (!(user.value.email_verified_at)) {
return navigateTo('/verify-email')
}
})

26
apps/web/nuxt.config.ts Normal file
View File

@@ -0,0 +1,26 @@
// 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',
echo: {
broadcaster: process.env.PUSHER_APP_BROADCASTER,
key: process.env.PUSHER_APP_KEY,
cluster: process.env.PUSHER_APP_CLUSTER,
wsHost: process.env.PUSHER_APP_HOST,
wsPort: process.env.PUSHER_APP_PORT,
forceTLS: process.env.PUSHER_APP_TLS,
encrypted: process.env.PUSHER_APP_ENCRYPTED,
disableStats: process.env.PUSHER_APP_DISABLE_STATS,
enabledTransports: ['ws', 'wss'],
},
},
},
imports: {
dirs: ['./utils'],
},
devtools: { enabled: true },
})

24
apps/web/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"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": {
"@iconify-json/heroicons": "^1.1.13",
"@nuxt/devtools": "latest",
"@nuxt/ui-pro": "^0.4.2",
"laravel-echo": "^1.15.3",
"nuxt": "^3.8.1",
"pusher-js": "^8.3.0",
"vue": "^3.3.8",
"vue-router": "^4.2.5"
}
}

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
definePageMeta({ middleware: ['guest'], layout: 'auth' })
const router = useRouter()
const { forgotPassword } = useAuth()
const form = ref()
const toast = useToast()
const state = ref({
email: '' as string,
})
const status = ref('')
const {
submit,
inProgress,
validationErrors,
} = useSubmit(
() => {
status.value = ''
return forgotPassword(state.value.email)
},
{
onSuccess: () => router.push('/login'),
onError: error => toast.add({ title: 'Error', description: error.message, color: 'red' }),
},
)
watch(validationErrors, async (errors) => {
if (errors.length > 0) {
await form.value?.setErrors(errors)
}
})
</script>
<template>
<UMain>
<UPage>
<div class="mx-auto flex min-h-screen w-full items-center justify-center">
<UCard class="w-96">
<template #header>
<div class="space-y-4 text-center ">
<h1 class="text-2xl font-bold">
Forgot Password
</h1>
<p class="text-sm">
Remember your password? <NuxtLink to="/login" class="text-primary hover:text-primary-300 font-semibold">
Login here
</NuxtLink>
</p>
</div>
</template>
<UForm ref="form" :state="state" class="space-y-8" @submit="submit">
<UFormGroup label="Email" name="email">
<UInput v-model="state.email" />
</UFormGroup>
<UButton block size="md" type="submit" :loading="inProgress" icon="i-heroicons-envelope">
Reset Password
</UButton>
</UForm>
<!-- <UDivider label="OR" class=" my-4"/> -->
</UCard>
</div>
</UPage>
</UMain>
</template>

15
apps/web/pages/index.vue Normal file
View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
definePageMeta({ middleware: ['auth'] })
const user = useUser()
console.log(user.value)
</script>
<template>
<div>
This is the Page Content
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
definePageMeta({ middleware: ['guest'], layout: 'auth' })
const router = useRouter()
const route = useRoute()
const { login } = useAuth()
const toast = useToast()
const form = ref()
const state = ref<LoginCredentials>({
email: '',
password: '',
remember: false,
})
const status = ref(
(route.query.reset ?? '').length > 0 ? atob(route.query.reset as string) : '',
)
const {
submit,
inProgress,
validationErrors,
} = useSubmit(
() => {
status.value = ''
return login(state.value)
},
{
onSuccess: () => router.push('/'),
onError: (error) => {
toast.add({ title: 'Error', description: error.message, color: 'red' })
},
},
)
watch(validationErrors, async (errors) => {
if (errors.length > 0) {
await form.value?.setErrors(errors)
}
})
</script>
<template>
<UMain>
<UPage>
<div class="mx-auto flex min-h-screen w-full items-center justify-center">
<UCard class="w-96">
<template #header>
<h1 class="text-center text-2xl font-bold">
Login
</h1>
</template>
<UForm ref="form" :state="state" class="space-y-4" @submit="submit">
<UFormGroup label="Email" name="email">
<UInput v-model="state.email" />
</UFormGroup>
<UFormGroup label="Password" name="password">
<UInput v-model="state.password" type="password" />
</UFormGroup>
<div class="flex items-center justify-between">
<UCheckbox id="remember-me" v-model="state.remember" label="Remember me" name="remember-me" />
<div class="text-sm leading-6">
<NuxtLink to="/forgot-password" class="text-primary hover:text-primary-300 font-semibold">
Forgot
password?
</NuxtLink>
</div>
</div>
<UButton block size="md" type="submit" :loading="inProgress" icon="i-heroicons-arrow-right-on-rectangle">
Login
</UButton>
</UForm>
<!-- <UDivider label="OR" class=" my-4"/> -->
</UCard>
</div>
</UPage>
</UMain>
</template>
<style scoped></style>

View File

@@ -0,0 +1,5 @@
<script setup lang="ts">
definePageMeta({ middleware: ['guest'], layout: 'auth' })
const { logout } = useAuth()
logout()
</script>

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
definePageMeta({ middleware: ['guest'], layout: 'auth' })
const router = useRouter()
const route = useRoute()
const { resetPassword } = useAuth()
if (!route.query.email) {
router.push('/')
}
const toast = useToast()
const state = ref<ResetPasswordCredentials>({
email: route.query.email as string,
password: '',
password_confirmation: '',
token: route.params.token as string,
})
const status = ref(
(route.query.reset ?? '').length > 0 ? atob(route.query.reset as string) : '',
)
const form = ref()
const {
submit,
inProgress,
validationErrors,
} = useSubmit(
() => {
status.value = ''
return resetPassword(state.value)
},
{
onSuccess: () => router.push('/'),
onError: (error) => {
toast.add({ title: 'Error', description: error.message, color: 'red' })
},
},
)
watch(validationErrors, async (errors) => {
if (errors.length > 0) {
await form.value?.setErrors(errors)
}
})
</script>
<template>
<UMain>
<UPage>
<div class="mx-auto flex min-h-screen w-full items-center justify-center">
<UCard class="w-96">
<template #header>
<h1 class="text-center text-2xl font-bold">
Reset Password
</h1>
</template>
<UForm ref="form" :state="state" class="space-y-4" @submit="submit">
<UFormGroup label="Email" name="email">
<UInput v-model="state.email" disabled />
</UFormGroup>
<UFormGroup label="Password" name="password">
<UInput v-model="state.password" type="password" />
</UFormGroup>
<UFormGroup label="Confirm Password" name="password_confirmation">
<UInput v-model="state.password_confirmation" type="password" />
</UFormGroup>
<UButton block size="md" type="submit" :loading="inProgress" icon="i-heroicon-lock-closed">
Change Password
</UButton>
</UForm>
<!-- <UDivider label="OR" class=" my-4"/> -->
</UCard>
</div>
</UPage>
</UMain>
</template>
<style scoped></style>

12
apps/web/plugins/auth.ts Normal file
View File

@@ -0,0 +1,12 @@
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()
})

View File

@@ -0,0 +1,53 @@
import Echo from 'laravel-echo'
import Pusher from 'pusher-js'
declare global {
interface Window {
Pusher: typeof Pusher
}
}
window.Pusher = Pusher
export default defineNuxtPlugin(() => {
const runtimeConfig = useRuntimeConfig()
const echo = new Echo({
broadcaster: runtimeConfig.public.echo.broadcaster,
key: runtimeConfig.public.echo.key,
cluster: 'mt1',
wsHost: runtimeConfig.public.echo.wsHost,
wsPort: runtimeConfig.public.echo.wsPort,
wssPort: runtimeConfig.public.echo.wsPort,
forceTLS: false,
encrypted: runtimeConfig.public.echo.encrypted,
disableStats: runtimeConfig.public.echo.disableStats,
enabledTransports: ['ws', 'wss'],
authorizer: (channel: { name: any }) => {
return {
authorize: async (socketId: string, callback: (error: any, respnse?: any) => void) => {
try {
const response = await $larafetch(`/broadcasting/auth`, {
method: 'POST',
body: {
socket_id: socketId,
channel_name: channel.name,
},
})
callback(null, response)
}
catch (error) {
callback(error)
}
},
}
},
})
// Make Echo instance available through the Nuxt app
return {
provide: {
echo,
},
}
})

View File

@@ -0,0 +1,19 @@
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')
}
})
})

BIN
apps/web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,3 @@
{
"extends": "../.nuxt/tsconfig.server.json"
}

4
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,4 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
}

View File

@@ -0,0 +1,74 @@
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 (status === 419) {
await initCsrf(true)
}
if ([500].includes(status)) {
console.error('[Laravel Error]', response.statusText, response._data)
}
},
})
async function initCsrf(forceRefresh = false) {
const { backendUrl } = useRuntimeConfig().public
const existingToken = useCookie(CSRF_COOKIE).value
if (existingToken && !forceRefresh) {
return existingToken
}
await $fetch('/sanctum/csrf-cookie', {
baseURL: backendUrl,
credentials: 'include',
})
return useCookie(CSRF_COOKIE).value
}