Refactor project structure and update dependencies

This commit is contained in:
2025-05-11 16:17:40 +02:00
parent 81d692a19b
commit 813f7b18d8
73 changed files with 10156 additions and 7756 deletions

15
nuxt/app/app.config.ts Normal file
View File

@@ -0,0 +1,15 @@
export default defineAppConfig({
name: 'Laravel/Nuxt Boilerplate',
ui: {
primary: 'sky',
gray: 'cool',
container: {
constrained: 'max-w-7xl w-full',
},
avatar: {
default: {
icon: 'i-heroicons-user',
},
},
},
})

10
nuxt/app/app.vue Normal file
View File

@@ -0,0 +1,10 @@
<script lang="ts" setup>
</script>
<template>
<UApp>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</UApp>
</template>

View File

@@ -0,0 +1,7 @@
@import "tailwindcss";
@import "@nuxt/ui";
@theme {
}

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
const items = [{
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>
<header
class="bg-background/75 backdrop-blur -mb-px sticky top-0 z-50 border-b border-dashed border-gray-200/80 dark:border-gray-800/80"
>
<UContainer class="flex items-center justify-between gap-3 h-16 py-2">
<AppLogo class="lg:flex-1" />
<UNavigationMenu
orientation="horizontal"
:items="items"
class="hidden lg:block"
/>
<div class="flex items-center justify-end gap-3 lg:flex-1">
<UserDropdown />
</div>
</UContainer>
</header>
</template>

View File

@@ -0,0 +1,8 @@
<template>
<NuxtLink
to="/"
class="font-bold text-xl"
>
<span class="text-primary-500">Nuxt</span> Breeze
</NuxtLink>
</template>

View File

@@ -0,0 +1,33 @@
<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>
<UNavigationMenu
class="data-[orientation=vertical]:w-48"
orientation="vertical"
:items="links"
/>
</template>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
const links = [
{
label: 'Account',
to: '/account',
icon: 'i-heroicons-user-solid',
},
{
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 #content>
<div class="p-4">
<UNavigationMenu
:items="links"
:orientation="'vertical'"
/>
</div>
</template>
</UPopover>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
import type { TableColumn } from '#ui/components/Table.vue'
import type { Device, IDevicesResponse } from '~/types/device'
const UButton = resolveComponent('UButton')
const UBadge = resolveComponent('UBadge')
const UDropdownMenu = resolveComponent('UDropdownMenu')
const dayjs = useDayjs()
const auth = useAuthStore()
const loading = ref(false)
const devices = ref<Device[]>([])
async function fetchData() {
loading.value = true
const response = await $fetch<IDevicesResponse>('devices')
if (response.ok) {
devices.value = response.devices
}
loading.value = false
}
const columns: TableColumn<Device>[] = [
{
accessorKey: 'name',
header: 'Device',
cell: ({ row }) => {
return h('div', { class: 'font-semibold' }, [
row.original.name,
row.original.is_current && h(UBadge, {
label: 'active',
color: 'primary',
variant: 'soft',
size: 'sm',
class: 'ms-1',
}),
h('div', { class: 'font-medium text-sm' }, `IP: ${row.original.ip}`),
])
},
},
{
accessorKey: 'last_used_at',
header: 'Last used at',
cell: ({ row }) => {
return dayjs(row.original.last_used_at).fromNow()
},
},
{
accessorKey: 'actions',
header: 'Actions',
cell: ({ row }) => {
return h('div', { class: 'flex justify-end' },
h(UDropdownMenu, {
items: items(row.original),
}, () => h(UButton, {
icon: 'i-heroicons-ellipsis-vertical-20-solid',
variant: 'ghost',
color: 'neutral',
})),
)
},
},
]
const items = (row: Device) => [
[
{
label: 'Delete',
icon: 'i-heroicons-trash-20-solid',
onSelect: async () => {
await $fetch<never>('devices/disconnect', {
method: 'POST',
body: {
hash: row.hash,
},
async onResponse({ response }) {
if (response._data?.ok) {
await fetchData()
await auth.fetchUser()
}
},
})
},
},
],
]
if (import.meta.client) {
fetchData()
}
</script>
<template>
<ClientOnly>
<UTable
:data="devices"
:columns="columns"
size="lg"
:loading="loading"
/>
</ClientOnly>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,125 @@
<script lang="ts" setup>
import type { IAccountChangePasswordResponse, IVerificationNotificationResponse } from '~/types/account'
const form = ref()
const auth = useAuthStore()
const state = reactive({
current_password: '',
password: '',
password_confirmation: '',
})
const { refresh: onSubmit, status: accountPasswordStatus } = useFetch<IAccountChangePasswordResponse>('account/password', {
method: 'POST',
body: state,
immediate: false,
watch: false,
async onResponse({ response }) {
if (response?.status === 422) {
form.value.setErrors(response._data?.errors)
}
else if (response._data?.ok) {
useToast().add({
icon: 'i-heroicons-check-circle-20-solid',
title: 'The password was successfully updated.',
color: 'primary',
})
state.current_password = ''
state.password = ''
state.password_confirmation = ''
}
},
})
const { refresh: sendResetPasswordEmail, status: resetPasswordEmailStatus } = useFetch<IVerificationNotificationResponse>('verification-notification', {
method: 'POST',
body: { email: auth.user.email },
immediate: false,
watch: false,
onResponse({ response }) {
if (response._data?.ok) {
useToast().add({
icon: 'i-heroicons-check-circle-20-solid',
title: 'A link to reset your password has been sent to your email.',
color: 'primary',
})
}
},
})
</script>
<template>
<div>
<UForm
v-if="auth.user.has_password"
ref="form"
:state="state"
class="space-y-4"
@submit="onSubmit"
>
<UFormField
label="Current Password"
name="current_password"
required
>
<UInput
v-model="state.current_password"
type="password"
autocomplete="off"
/>
</UFormField>
<UFormField
label="New Password"
name="password"
hint="min 8 characters"
:ui="{ hint: 'text-xs text-gray-500 dark:text-gray-400' }"
required
>
<UInput
v-model="state.password"
type="password"
autocomplete="off"
/>
</UFormField>
<UFormField
label="Repeat Password"
name="password_confirmation"
required
>
<UInput
v-model="state.password_confirmation"
type="password"
autocomplete="off"
/>
</UFormField>
<div class="pt-2">
<UButton
type="submit"
label="Save"
:loading="accountPasswordStatus === 'pending'"
/>
</div>
</UForm>
<UAlert
v-else
icon="i-heroicons-information-circle-20-solid"
title="Send a link to your email to reset your password."
description="To create a password for your account, you must go through the password recovery process."
:actions="[
{
label: 'Send link to Email',
variant: 'solid',
color: 'neutral',
loading: resetPasswordEmailStatus === 'pending',
onClick: () => sendResetPasswordEmail(),
},
]"
/>
</div>
</template>

View File

@@ -0,0 +1,128 @@
<script lang="ts" setup>
import type { IAccountUpdateResponse, IVerificationNotificationResponse } from '~/types/account'
const form = ref()
const auth = useAuthStore()
const state = reactive({
...{
email: auth.user.email,
name: auth.user.name,
avatar: auth.user.avatar,
},
})
const { refresh: sendEmailVerification, status: resendEmailStatus } = useFetch<IVerificationNotificationResponse>('verification-notification', {
method: 'POST',
body: { email: state.email },
immediate: false,
watch: false,
onResponse({ response }) {
if (response._data?.ok) {
useToast().add({
icon: 'i-heroicons-check-circle-20-solid',
title: response._data.message,
color: 'primary',
})
}
},
})
const { refresh: onSubmit, status: accountUpdateStatus } = useFetch<IAccountUpdateResponse>('account/update', {
method: 'POST',
body: state,
immediate: false,
watch: false,
async onResponse({ response }) {
if (response?.status === 422) {
form.value.setErrors(response._data?.errors)
}
else if (response._data?.ok) {
useToast().add({
icon: 'i-heroicons-check-circle-20-solid',
title: 'Account details have been successfully updated.',
color: 'primary',
})
await auth.fetchUser()
state.name = auth.user.name
state.email = auth.user.email
state.avatar = auth.user.avatar
}
},
})
</script>
<template>
<UForm
ref="form"
:state="state"
class="space-y-4"
@submit="onSubmit"
>
<UFormField
label=""
name="avatar"
class="flex"
>
<InputUploadAvatar
v-model="state.avatar"
accept=".png, .jpg, .jpeg, .webp"
entity="avatars"
max-size="2"
/>
</UFormField>
<UFormField
label="Name"
name="name"
required
>
<UInput
v-model="state.name"
type="text"
/>
</UFormField>
<UFormField
label="Email"
name="email"
required
>
<UInput
v-model="state.email"
placeholder="you@example.com"
icon="i-heroicons-envelope"
trailing
type="email"
/>
</UFormField>
<UAlert
v-if="auth.user.must_verify_email"
variant="subtle"
color="warning"
icon="i-heroicons-information-circle-20-solid"
title="Please confirm your email address."
description="A confirmation email has been sent to your email address. Please click on the confirmation link in the email to verify your email address."
:actions="[
{
label: 'Resend verification email',
variant: 'solid',
color: 'neutral',
loading: resendEmailStatus === 'pending',
onClick: () => sendEmailVerification(),
},
]"
/>
<div class="pt-2">
<UButton
type="submit"
label="Save"
:loading="accountUpdateStatus === 'pending'"
/>
</div>
</UForm>
</template>

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import type { IForgotPasswordResponse } from '~/types/account'
const form = useTemplateRef('form')
const state = reactive({
email: '',
})
const { refresh: onSubmit, status: forgotStatus } = useFetch<IForgotPasswordResponse>('forgot-password', {
method: 'POST',
body: state,
immediate: false,
watch: false,
async onResponse({ response }) {
if (response?.status === 422) {
form.value.setErrors(response._data?.errors)
}
else if (response._data?.ok) {
useToast().add({
title: 'Success',
description: response._data.message,
color: 'primary',
})
}
},
})
</script>
<template>
<UForm
ref="form"
:state="state"
class="space-y-8"
@submit="onSubmit"
>
<UFormField
label="Email"
name="email"
>
<UInput
v-model="state.email"
class="w-full"
/>
</UFormField>
<UButton
block
size="md"
type="submit"
:loading="forgotStatus === 'pending'"
icon="i-heroicons-envelope"
>
Reset Password
</UButton>
</UForm>
</template>

View File

@@ -0,0 +1,173 @@
<script setup lang="ts">
import type { IAccountLoginResponse, IAccountProvider, IAccountProviderData } from '~/types/account'
import type { ButtonProps } from '#ui/components/Button.vue'
const config = useRuntimeConfig()
const router = useRouter()
const auth = useAuthStore()
const form = useTemplateRef('form')
const state = reactive({
email: '',
password: '',
remember: false,
})
const { refresh: onSubmit, status: loginStatus } = useFetch<IAccountLoginResponse>('login', {
method: 'POST',
body: state,
immediate: false,
watch: false,
async onResponse({ response }) {
if (response?.status === 422) {
form?.value?.setErrors(response._data?.errors)
}
else if (response._data?.ok) {
auth.token = response._data.token
await auth.fetchUser()
await router.push('/')
}
},
})
const providers = ref<{ [key: string]: IAccountProvider }>(
Object.fromEntries(
Object.entries(config.public.providers).map(([key, provider]) => [
key,
{
...provider,
color: provider.color as ButtonProps['color'],
},
]),
),
)
async function handleMessage(event: { data: IAccountProviderData }): Promise<void> {
const provider = event.data.provider as string
if (Object.keys(providers.value).includes(provider) && event.data.token) {
if (providers?.value[provider]?.loading) {
providers.value[provider].loading = false
}
auth.token = event.data.token
await auth.fetchUser()
await router.push('/')
}
else if (event.data.message) {
useToast().add({
icon: 'i-heroicons-exclamation-circle-solid',
color: 'error',
title: event.data.message,
})
}
}
function loginVia(provider: string): void {
providers.value[provider]!.loading = true
const width = 640
const height = 660
const left = window.screen.width / 2 - width / 2
const top = window.screen.height / 2 - height / 2
const popup = window.open(
`${config.public.apiBase}${config.public.apiPrefix}/login/${provider}/redirect`,
'Sign In',
`toolbar=no, location=no, directories=no, status=no, menubar=no, scollbars=no, resizable=no, copyhistory=no, width=${width},height=${height},top=${top},left=${left}`,
)
const interval = setInterval(() => {
if (!popup || popup.closed) {
clearInterval(interval)
providers.value[provider]!.loading = false
}
}, 500)
}
onMounted(() => window.addEventListener('message', handleMessage))
onBeforeUnmount(() => window.removeEventListener('message', handleMessage))
</script>
<template>
<UForm
ref="form"
:state="state"
class="space-y-4"
@submit="onSubmit"
>
<UFormField
label="Email"
name="email"
>
<UInput
v-model="state.email"
class="w-full"
/>
</UFormField>
<UFormField
label="Password"
name="password"
>
<UInput
v-model="state.password"
type="password"
class="w-full"
/>
</UFormField>
<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="loginStatus === 'pending'"
icon="i-heroicons-arrow-right-on-rectangle"
>
Login
</UButton>
</UForm>
<USeparator
v-if="Object.keys(providers).length > 0"
color="neutral"
label="Login with"
class="my-4"
/>
<div class="flex gap-4">
<UButton
v-for="(provider, key) in providers"
:key="key"
:loading="provider.loading"
:icon="provider.icon"
:color="provider.color"
:label="provider.name"
size="lg"
class="w-full flex items-center justify-center"
@click="loginVia(key as string)"
/>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import type { IAccountResetPasswordResponse } from '~/types/account'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const form = useTemplateRef('form')
const state = reactive({
email: route.query.email as string,
token: route.params.token,
password: '',
password_confirmation: '',
})
const { refresh: onSubmit, status: resetStatus } = useFetch<IAccountResetPasswordResponse>('reset-password', {
method: 'POST',
body: state,
immediate: false,
watch: false,
async onResponse({ response }) {
if (response?.status === 422) {
form?.value?.setErrors(response._data?.errors)
}
else if (response._data?.ok) {
useToast().add({
title: 'Success',
description: response._data.message,
color: 'primary',
})
if (auth.isLoggedIn) {
await auth.fetchUser()
await router.push('/')
}
else {
await router.push('/login')
}
}
},
})
</script>
<template>
<UForm
ref="form"
:state="state"
class="space-y-4"
@submit="onSubmit"
>
<UFormField
label="Email"
name="email"
>
<UInput
v-model="state.email"
disabled
/>
</UFormField>
<UFormField
label="Password"
name="password"
>
<UInput
v-model="state.password"
type="password"
/>
</UFormField>
<UFormField
label="Confirm Password"
name="password_confirmation"
>
<UInput
v-model="state.password_confirmation"
type="password"
/>
</UFormField>
<UButton
block
size="md"
type="submit"
:loading="resetStatus === 'pending'"
icon="i-heroicon-lock-closed"
>
Change Password
</UButton>
</UForm>
</template>

View File

@@ -0,0 +1,131 @@
<script lang="ts" setup>
import type { IUploadResponse } from '~/types/account'
interface Props {
modelValue: string
entity: string
accept: string
maxSize: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const { $storage } = useNuxtApp()
const value = computed({
get(): string {
return props.modelValue
},
set(value: string) {
emit('update:modelValue', value)
},
})
const inputRef = ref<HTMLInputElement>()
const loading = ref<boolean>(false)
const onSelect = async (e: Event) => {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
target.value = ''
if (file.size > props.maxSize * 1024 * 1024) {
return useToast().add({
title: 'File is too large.',
color: 'error',
icon: 'i-heroicons-exclamation-circle-solid',
})
}
loading.value = true
const formData = new FormData()
formData.append('image', file)
await $fetch<IUploadResponse>('upload', {
method: 'POST',
body: formData,
params: {
entity: props.entity,
width: 300,
height: 300,
},
ignoreResponseError: true,
onResponse({ response }) {
if (response.status !== 200) {
useToast().add({
icon: 'i-heroicons-exclamation-circle-solid',
color: 'error',
title: response._data?.message ?? response.statusText ?? 'Something went wrong',
})
}
else if (response._data?.ok) {
value.value = response._data?.path
}
loading.value = false
},
})
}
</script>
<template>
<div class="flex gap-6">
<div class="relative flex">
<UAvatar
:src="$storage(value)"
size="3xl"
img-class="object-cover"
/>
<UTooltip
text="Upload avatar"
class="absolute top-0 end-0 -m-2"
:popper="{ placement: 'right' }"
>
<UButton
type="button"
color="neutral"
variant="link"
icon="i-heroicons-cloud-arrow-up"
size="xs"
:ui="{ base: 'rounded-full' }"
:loading="loading"
@click="inputRef.click()"
/>
</UTooltip>
<UTooltip
text="Delete avatar"
class="absolute bottom-0 end-0 -m-2"
:popper="{ placement: 'right' }"
>
<UButton
type="button"
color="neutral"
variant="link"
icon="i-heroicons-x-mark-20-solid"
size="xs"
:ui="{ base: 'rounded-full' }"
:disabled="loading"
@click="value = ''"
/>
</UTooltip>
<input
ref="inputRef"
type="file"
class="hidden"
:accept="accept"
@change="onSelect"
>
</div>
<div class="text-sm opacity-80">
<div>Max upload size: {{ maxSize }}Mb</div>
<div>Accepted formats: {{ accept }}</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,25 @@
<script lang="ts" setup>
const modal = useModal()
</script>
<template>
<UModal>
<UCard>
<template #header>
<div class="text-2xl leading-tight font-black">
Welcome to LaravelNuxt
</div>
</template>
<USkeleton class="w-full h-60" />
<template #footer>
<UButton
label="Close"
color="gray"
@click="modal.close"
/>
</template>
</UCard>
</UModal>
</template>

33
nuxt/app/error.vue Normal file
View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { NuxtError } from '#app'
const props = defineProps<{ error: NuxtError }>()
const handleError = () => clearError({ redirect: '/' })
</script>
<template>
<UContainer class="py-5 flex items-center justify-center">
<AppLogo />
</UContainer>
<UContainer class="flex-grow flex flex-col items-center justify-center space-y-5">
<h1 class="text-9xl font-bold">
{{ props.error?.statusCode }}
</h1>
<div>{{ props.error?.message }}</div>
<div
v-if="props.error?.statusCode >= 500"
>
{{ error?.stack }}
</div>
<div>
<UButton
color="neutral"
size="xl"
variant="outline"
label="Go back"
@click="handleError"
/>
</div>
</UContainer>
</template>

7
nuxt/app/index.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
declare module '#app' {
interface NuxtApp {
$storage(msg: string): string
}
}
export { }

View File

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

View File

@@ -0,0 +1,17 @@
<template>
<div>
<AppHeader />
<UContainer class="flex justify-start mt-4">
<AppNavigation />
<UContainer
as="main"
class="flex-grow py-4 sm:py-7 flex flex-col"
>
<slot />
</UContainer>
</UContainer>
</div>
</template>
<script setup lang="ts">
</script>

View File

@@ -0,0 +1,8 @@
export default defineNuxtRouteMiddleware(() => {
const nuxtApp = useNuxtApp()
const auth = useAuthStore()
if (!auth.isLoggedIn) {
return nuxtApp.runWithContext(() => navigateTo('/login'))
}
})

View File

@@ -0,0 +1,8 @@
export default defineNuxtRouteMiddleware(() => {
const nuxtApp = useNuxtApp()
const auth = useAuthStore()
if (auth.isLoggedIn) {
return nuxtApp.runWithContext(() => navigateTo('/'))
}
})

View File

@@ -0,0 +1,16 @@
export default defineNuxtRouteMiddleware(() => {
const nuxtApp = useNuxtApp()
const auth = useAuthStore()
if (auth.isLoggedIn && !auth.user.roles.includes('admin')) {
return nuxtApp.runWithContext(() => {
useToast().add({
icon: 'i-heroicons-exclamation-circle-solid',
title: 'Access denied.',
color: 'error',
})
return navigateTo('/')
})
}
})

View File

@@ -0,0 +1,16 @@
export default defineNuxtRouteMiddleware(() => {
const nuxtApp = useNuxtApp()
const auth = useAuthStore()
if (auth.isLoggedIn && !auth.user.roles.includes('user')) {
return nuxtApp.runWithContext(() => {
useToast().add({
icon: 'i-heroicons-exclamation-circle-solid',
title: 'Access denied.',
color: 'error',
})
return navigateTo('/')
})
}
})

View File

@@ -0,0 +1,16 @@
export default defineNuxtRouteMiddleware(() => {
const nuxtApp = useNuxtApp()
const auth = useAuthStore()
if (auth.isLoggedIn && auth.user.must_verify_email) {
return nuxtApp.runWithContext(() => {
useToast().add({
icon: 'i-heroicons-exclamation-circle-solid',
title: 'Please confirm your email.',
color: 'error',
})
return navigateTo('/account/general')
})
}
})

View File

@@ -0,0 +1,30 @@
<script lang="ts" setup>
definePageMeta({
middleware: ['auth'],
})
const links = [
[
{
label: 'Account',
icon: 'i-heroicons-user',
to: '/account/general',
},
{
label: 'Devices',
icon: 'i-heroicons-device-phone-mobile',
to: '/account/devices',
},
],
]
</script>
<template>
<div>
<UNavigationMenu
:items="links"
class="border-b border-gray-200 dark:border-gray-800 mb-4"
/>
<NuxtPage class="col-span-10 md:col-span-8" />
</div>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<UCard>
<AccountDeviceTable />
</UCard>
</template>

View File

@@ -0,0 +1,29 @@
<script lang="ts" setup></script>
<template>
<UCard :ui="{ body: 'grid grid-cols-12 gap-6 md:gap-8' }">
<div class="col-span-12 lg:col-span-4">
<div class="text-lg font-semibold mb-2">
Profile information
</div>
<div class="text-sm opacity-80">
Update your account's profile information and email address.
</div>
</div>
<div class="col-span-12 lg:col-span-8">
<AccountUpdateProfile />
</div>
<USeparator class="col-span-12" />
<div class="col-span-12 lg:col-span-4">
<div class="text-lg font-semibold mb-2">
Update Password
</div>
<div class="text-sm opacity-80">
Ensure your account is using a long, random password to stay secure.
</div>
</div>
<div class="col-span-12 lg:col-span-8">
<AccountUpdatePassword />
</div>
</UCard>
</template>

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup>
definePageMeta({
redirect: '/account/general',
})
</script>
<template>
<div />
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import AuthForgotPasswordForm from '~/components/auth/AuthForgotPasswordForm.vue'
definePageMeta({ middleware: ['guest'], layout: 'auth' })
</script>
<template>
<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-bold"
>
Login here
</NuxtLink>
</p>
</div>
</template>
<AuthForgotPasswordForm />
<!-- <UDivider label="OR" class=" my-4"/> -->
</UCard>
</div>
</template>

9
nuxt/app/pages/index.vue Normal file
View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
definePageMeta({ middleware: ['auth'] })
</script>
<template>
<div>
This is the Page Content
</div>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import AuthLoginForm from '~/components/auth/AuthLoginForm.vue'
definePageMeta({ middleware: ['guest'], layout: 'auth' })
</script>
<template>
<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>
<AuthLoginForm />
<!-- <UDivider label="OR" class=" my-4"/> -->
</UCard>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,9 @@
<template>
<div />
</template>
<script setup lang="ts">
definePageMeta({ middleware: ['auth'], layout: 'auth' })
const auth = useAuthStore()
auth.logout()
</script>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import AuthResetPasswordForm from '~/components/auth/AuthResetPasswordForm.vue'
definePageMeta({ middleware: ['guest'], layout: 'auth' })
</script>
<template>
<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>
<AuthResetPasswordForm />
<!-- <UDivider label="OR" class=" my-4"/> -->
</UCard>
</div>
</template>
<style scoped></style>

130
nuxt/app/plugins/app.ts Normal file
View File

@@ -0,0 +1,130 @@
import { ofetch } from 'ofetch'
import type { FetchOptions } from 'ofetch'
export default defineNuxtPlugin({
name: 'app',
enforce: 'default',
parallel: true,
async setup(nuxtApp) {
const config = useRuntimeConfig()
const auth = useAuthStore()
nuxtApp.provide('storage', (path: string): string => {
if (!path) return ''
return path.startsWith('http://') || path.startsWith('https://')
? path
: config.public.storageBase + path
})
function buildHeaders(headers: HeadersInit = {}): HeadersInit {
// Initial headers with Accept
const initialHeaders = {
...headers,
Accept: 'application/json',
}
// Conditionally add server-specific headers
if (import.meta.server) {
const serverHeaders = {
referer: useRequestURL().toString(),
...useRequestHeaders(['x-forwarded-for', 'user-agent', 'referer']),
}
Object.assign(initialHeaders, serverHeaders)
}
// Conditionally add authorization header if logged in
if (auth.isLoggedIn) {
const authHeaders = {
Authorization: `Bearer ${auth.token}`,
}
Object.assign(initialHeaders, authHeaders)
}
return initialHeaders
}
function buildBaseURL(baseURL: string): string {
if (baseURL) return baseURL
return import.meta.server
? config.apiLocal + config.public.apiPrefix
: config.public.apiBase + config.public.apiPrefix
}
function buildSecureMethod(options: FetchOptions): void {
if (import.meta.server) return
const method = options.method?.toLowerCase() ?? 'get'
if (options.body instanceof FormData && method === 'put') {
options.method = 'POST'
options.body.append('_method', 'PUT')
}
}
function isRequestWithAuth(baseURL: string, path: string): boolean {
return !baseURL
&& !path.startsWith('/_nuxt')
&& !path.startsWith('http://')
&& !path.startsWith('https://')
}
globalThis.$fetch = ofetch.create({
retry: false,
onRequest({ request, options }) {
if (!isRequestWithAuth(options.baseURL ?? '', request.toString())) return
options.credentials = 'include'
options.baseURL = buildBaseURL(options.baseURL ?? '')
options.headers = buildHeaders(options.headers)
buildSecureMethod(options)
},
onRequestError({ error }) {
if (import.meta.server) return
if (error.name === 'AbortError') return
useToast().add({
icon: 'i-heroicons-exclamation-circle-solid',
color: 'red',
title: error.message ?? 'Something went wrong',
})
},
onResponseError({ response }) {
if (response.status === 401) {
if (auth.isLoggedIn) {
auth.token = ''
auth.user = {} as User
}
if (import.meta.client) {
useToast().add({
title: 'Please log in to continue',
icon: 'i-heroicons-exclamation-circle-solid',
color: 'primary',
})
}
}
else if (response.status !== 422) {
if (import.meta.client) {
useToast().add({
icon: 'i-heroicons-exclamation-circle-solid',
color: 'red',
title: response._data?.message ?? response.statusText ?? 'Something went wrong',
})
}
}
},
} as FetchOptions)
if (auth.isLoggedIn) {
await auth.fetchUser()
}
},
})

53
nuxt/app/stores/auth.ts Normal file
View File

@@ -0,0 +1,53 @@
import { defineStore } from 'pinia'
import type { IAccountLogoutResponse } from '~/types/account'
export type User = {
ulid: string
name: string
email: string
avatar: string
must_verify_email: boolean
has_password: boolean
roles: string[]
providers: string[]
}
export const useAuthStore = defineStore('auth', () => {
const config = useRuntimeConfig()
const nuxtApp = useNuxtApp()
const user = ref(<User>{})
const token = useCookie('token', {
path: '/',
sameSite: 'strict',
secure: config.public.apiBase.startsWith('https://'),
maxAge: 60 * 60 * 24 * 365,
})
const isLoggedIn = computed(() => !!token.value)
const { refresh: logout } = useFetch<IAccountLogoutResponse>('logout', {
method: 'POST',
immediate: false,
onResponse({ response }) {
if (response.status === 200) {
token.value = ''
user.value = <User>{}
return nuxtApp.runWithContext(() => {
return navigateTo('/')
})
}
},
})
const { refresh: fetchUser } = useFetch<{ user: User }>('user', {
immediate: false,
onResponse({ response }) {
if (response.status === 200) {
user.value = response._data.user
}
},
})
return { user, isLoggedIn, logout, fetchUser, token }
})

51
nuxt/app/types/account.d.ts vendored Normal file
View File

@@ -0,0 +1,51 @@
import type { ButtonProps } from '#ui/components/Button.vue'
export interface IAccountLoginResponse {
ok: boolean
token: string
}
export interface IAccountLogoutResponse {
ok: boolean
}
export interface IAccountProviderData {
provider: string
token: string
message?: string
}
export interface IAccountChangePasswordResponse {
ok: boolean
}
export interface IAccountResetPasswordResponse {
ok: boolean
}
export interface IVerificationNotificationResponse {
ok: boolean
message: string
}
export interface IAccountUpdateResponse {
ok: boolean
}
export interface IForgotPasswordResponse {
ok: boolean
message: string
}
export interface IAccountProvider {
name: string
icon: string
color: ButtonProps['color']
loading?: boolean
}
export interface IUploadResponse {
ok: boolean
path: string
message?: string
}

12
nuxt/app/types/device.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
export type Device = {
name: string
ip: string
last_used_at: string
is_current: boolean
hash: string
}
export interface IDevicesResponse {
ok: boolean
devices: Device[]
}

0
nuxt/app/types/responses.d.ts vendored Normal file
View File