[Closes #8] Added authentication
All checks were successful
Production Build and Deploy / Build (push) Successful in 1m7s
Production Build and Deploy / Deploy (push) Successful in 29s

This commit is contained in:
Liviu Burcusel 2026-01-07 11:11:35 +01:00
parent 6eefa137bb
commit 6d3cdb560d
Signed by: liviu
GPG key ID: 6CDB37A4AD2C610C
65 changed files with 5834 additions and 440 deletions

View file

@ -25,6 +25,10 @@ jobs:
fetch-depth: 0
- name: Install dependencies
run: npm ci
env:
DATABASE_URL: "N/A"
BETTER_AUTH_SECRET: "N/A"
BETTER_AUTH_URL: "N/A"
- name: Run tests and generate coverage
run: npm run coverage
# continue-on-error: true
@ -38,6 +42,9 @@ jobs:
- name: Build site
env:
NITRO_PRESET: node_cluster
DATABASE_URL: "N/A"
BETTER_AUTH_SECRET: "N/A"
BETTER_AUTH_URL: "N/A"
run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v3

View file

@ -22,6 +22,10 @@ jobs:
fetch-depth: 0
- name: Install dependencies
run: npm ci
env:
DATABASE_URL: "N/A"
BETTER_AUTH_SECRET: "N/A"
BETTER_AUTH_URL: "N/A"
- name: Run tests and generate coverage
run: npm run coverage
# continue-on-error: true
@ -32,7 +36,7 @@ jobs:
with:
args: >
"-Dsonar.projectKey=GF-dev"
"-Dsonar.projectName=Glowing Fiesta (DEV)"
"-Dsonar.projectName=Glowing Fiesta (dev)"
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}

1
.husky/pre-commit Normal file
View file

@ -0,0 +1 @@
npm exec lint-staged

View file

@ -0,0 +1,65 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Frown } from "lucide-vue-next";
const authStore = useAuthStore();
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
const email = ref("");
const password = ref("");
const doLogin = async () => {
await authStore.signIn(email.value, password.value);
};
</script>
<template>
<div :class="cn('flex flex-col gap-6', props.class)">
<Card>
<CardHeader class="text-center">
<CardTitle class="text-xl">Login</CardTitle>
<CardDescription>Enter your email below to login</CardDescription>
</CardHeader>
<CardContent>
<form autocomplete="off" @submit.prevent="doLogin">
<FieldGroup>
<Field>
<FieldLabel for="email">Email</FieldLabel>
<Input id="email" v-model="email" type="email" placeholder="m@example.com" required />
</Field>
<Field>
<FieldLabel for="password">Password</FieldLabel>
<Input id="password" v-model="password" type="password" required />
</Field>
<Field>
<Button type="submit">Login</Button>
<FieldDescription class="text-center">
Don't have an account?
<NuxtLink to="/member/auth/create-account">
<Button variant="link">Create account</Button>
</NuxtLink>
</FieldDescription>
</Field>
<Field v-if="authStore.lastError" variant="error">
<FieldDescription class="text-destructive flex items-center gap-2">
<Frown /> {{ authStore.lastError }}
</FieldDescription>
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
<FieldDescription class="px-6 text-center">
<NuxtLink to="/"><Button variant="link">Terms of Service</Button></NuxtLink>
<NuxtLink to="/"><Button variant="link">Privacy Policy</Button></NuxtLink>
</FieldDescription>
</div>
</template>

View file

@ -0,0 +1,81 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { authClient } from "~~/shared/utils/auth-client";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
const name = ref("");
const email = ref("");
const password = ref("");
const confirmPassword = ref("");
const createAccount = async () => {
await authClient.signUp.email({
name: name.value,
email: email.value,
password: password.value,
// callbackURL: "/",
});
};
</script>
<template>
<div :class="cn('flex flex-col gap-6', props.class)">
<ClientOnly>
<Card>
<CardHeader class="text-center">
<CardTitle class="text-xl">Create your account</CardTitle>
<CardDescription>Enter your email below to create your account</CardDescription>
</CardHeader>
<CardContent>
<form autocomplete="off" @submit.prevent="createAccount">
<FieldGroup>
<Field>
<FieldLabel for="name">Full Name</FieldLabel>
<Input id="name" v-model="name" type="text" placeholder="John Doe" required />
</Field>
<Field>
<FieldLabel for="email">Email</FieldLabel>
<Input id="email" v-model="email" type="email" placeholder="m@example.com" required />
</Field>
<Field>
<Field class="grid grid-cols-2 gap-4">
<Field>
<FieldLabel for="password">Password</FieldLabel>
<Input id="password" v-model="password" type="password" required />
</Field>
<Field>
<FieldLabel for="confirm-password">Confirm Password</FieldLabel>
<Input id="confirm-password" v-model="confirmPassword" type="password" required />
</Field>
</Field>
<FieldDescription>Must be at least 8 characters long.</FieldDescription>
</Field>
<Field>
<Button type="submit">Create Account</Button>
<FieldDescription class="text-center">
Already have an account?
<NuxtLink to="/member/auth/login">
<Button variant="link">Log in</Button>
</NuxtLink>
</FieldDescription>
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
</ClientOnly>
<FieldDescription class="px-6 text-center">
<NuxtLink to="/"><Button variant="link">Terms of Service</Button></NuxtLink>
<NuxtLink to="/"><Button variant="link">Privacy Policy</Button></NuxtLink>
</FieldDescription>
</div>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<div
data-slot="card"
:class="cn('bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm', props.class)"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<div data-slot="card-action" :class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<div data-slot="card-content" :class="cn('px-6', props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<p data-slot="card-description" :class="cn('text-muted-foreground text-sm', props.class)">
<slot />
</p>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<div data-slot="card-footer" :class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<div
data-slot="card-header"
:class="
cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
props.class
)
"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<h3 data-slot="card-title" :class="cn('leading-none font-semibold', props.class)">
<slot />
</h3>
</template>

View file

@ -0,0 +1,7 @@
export { default as Card } from "./Card.vue";
export { default as CardAction } from "./CardAction.vue";
export { default as CardContent } from "./CardContent.vue";
export { default as CardDescription } from "./CardDescription.vue";
export { default as CardFooter } from "./CardFooter.vue";
export { default as CardHeader } from "./CardHeader.vue";
export { default as CardTitle } from "./CardTitle.vue";

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import type { FieldVariants } from ".";
import { cn } from "@/lib/utils";
import { fieldVariants } from ".";
const props = defineProps<{
class?: HTMLAttributes["class"];
orientation?: FieldVariants["orientation"];
}>();
</script>
<template>
<div role="group" data-slot="field" :data-orientation="orientation" :class="cn(fieldVariants({ orientation }), props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<div data-slot="field-content" :class="cn('group/field-content flex flex-1 flex-col gap-1.5 leading-snug', props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,24 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<p
data-slot="field-description"
:class="
cn(
'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
props.class
)
"
>
<slot />
</p>
</template>

View file

@ -0,0 +1,50 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { computed } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"];
errors?: Array<string | { message: string | undefined } | undefined>;
}>();
const content = computed(() => {
if (!props.errors || props.errors.length === 0) return null;
const uniqueErrors = [
...new Map(
props.errors.filter(Boolean).map((error) => {
const message = typeof error === "string" ? error : error?.message;
return [message, error];
})
).values(),
];
if (uniqueErrors.length === 1 && uniqueErrors[0]) {
return typeof uniqueErrors[0] === "string" ? uniqueErrors[0] : uniqueErrors[0].message;
}
return uniqueErrors.map((error) => (typeof error === "string" ? error : error?.message));
});
</script>
<template>
<div
v-if="$slots.default || content"
role="alert"
data-slot="field-error"
:class="cn('text-destructive text-sm font-normal', props.class)"
>
<slot v-if="$slots.default" />
<template v-else-if="typeof content === 'string'">
{{ content }}
</template>
<ul v-else-if="Array.isArray(content)" class="ml-4 flex list-disc flex-col gap-1">
<li v-for="(error, index) in content" :key="index">
{{ error }}
</li>
</ul>
</div>
</template>

View file

@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<div
data-slot="field-group"
:class="
cn(
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
props.class
)
"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,25 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<Label
data-slot="field-label"
:class="
cn(
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4',
'has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10',
props.class
)
"
>
<slot />
</Label>
</template>

View file

@ -0,0 +1,19 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"];
variant?: "legend" | "label";
}>();
</script>
<template>
<legend
data-slot="field-legend"
:data-variant="variant"
:class="cn('mb-3 font-medium', 'data-[variant=legend]:text-base', 'data-[variant=label]:text-sm', props.class)"
>
<slot />
</legend>
</template>

View file

@ -0,0 +1,26 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<div
data-slot="field-separator"
:data-content="!!$slots.default"
:class="cn('relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2', props.class)"
>
<Separator class="absolute inset-0 top-1/2" />
<span
v-if="$slots.default"
class="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
<slot />
</span>
</div>
</template>

View file

@ -0,0 +1,19 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<fieldset
data-slot="field-set"
:class="
cn('flex flex-col gap-6', 'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3', props.class)
"
>
<slot />
</fieldset>
</template>

View file

@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";
const props = defineProps<{
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<div
data-slot="field-label"
:class="
cn(
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
props.class
)
"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,36 @@
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
export const fieldVariants = cva("group/field flex w-full gap-3 data-[invalid=true]:text-destructive", {
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
});
export type FieldVariants = VariantProps<typeof fieldVariants>;
export { default as Field } from "./Field.vue";
export { default as FieldContent } from "./FieldContent.vue";
export { default as FieldDescription } from "./FieldDescription.vue";
export { default as FieldError } from "./FieldError.vue";
export { default as FieldGroup } from "./FieldGroup.vue";
export { default as FieldLabel } from "./FieldLabel.vue";
export { default as FieldLegend } from "./FieldLegend.vue";
export { default as FieldSeparator } from "./FieldSeparator.vue";
export { default as FieldSet } from "./FieldSet.vue";
export { default as FieldTitle } from "./FieldTitle.vue";

View file

@ -0,0 +1,26 @@
<script setup lang="ts">
import type { LabelProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { Label } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps<LabelProps & { class?: HTMLAttributes["class"] }>();
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<Label
data-slot="label"
v-bind="delegatedProps"
:class="
cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
props.class
)
"
>
<slot />
</Label>
</template>

View file

@ -0,0 +1 @@
export { default as Label } from "./Label.vue";

View file

@ -0,0 +1,42 @@
<script lang="ts" setup>
import type { ToasterProps } from "vue-sonner";
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon, XIcon } from "lucide-vue-next";
import { Toaster as Sonner } from "vue-sonner";
import { cn } from "@/lib/utils";
const props = defineProps<ToasterProps>();
</script>
<template>
<Sonner
:class="cn('toaster group', props.class)"
:style="{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
'--border-radius': 'var(--radius)',
}"
v-bind="props"
>
<template #success-icon>
<CircleCheckIcon class="size-4" />
</template>
<template #info-icon>
<InfoIcon class="size-4" />
</template>
<template #warning-icon>
<TriangleAlertIcon class="size-4" />
</template>
<template #error-icon>
<OctagonXIcon class="size-4" />
</template>
<template #loading-icon>
<div>
<Loader2Icon class="size-4 animate-spin" />
</div>
</template>
<template #close-icon>
<XIcon class="size-4" />
</template>
</Sonner>
</template>

View file

@ -0,0 +1 @@
export { default as Toaster } from "./Sonner.vue";

View file

@ -1,4 +1,5 @@
<script setup lang="ts">
import { useAuthStore } from "~/stores/auth";
import DefaultSidebar from "~/layouts/default/Sidebar.vue";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "~/components/ui/sidebar";
@ -14,12 +15,19 @@ import {
import { Separator } from "~/components/ui/separator";
import { useRuntimeConfig } from "#app";
const currentYear = new Date().getFullYear();
const config = useRuntimeConfig();
const authStore = useAuthStore();
await authStore.init();
</script>
<template>
<SidebarProvider>
<DefaultSidebar />
<DefaultSidebar :user="authStore.user" />
<SidebarInset>
<header class="flex h-12 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger class="-ml-1" />
@ -40,8 +48,12 @@ const currentYear = new Date().getFullYear();
<slot />
</main>
<footer class="flex h-12 shrink-0 items-center gap-2 border-b px-4" data-testid="footer">
<div v-if="currentYear === 2025" class="bg-muted/50 flex-1 rounded-xl p-2 text-center">Glowing Fiesta 2025</div>
<div v-else class="bg-muted/50 flex-1 rounded-xl p-2 text-center">Glowing Fiesta 2025 - {{ currentYear }}</div>
<div v-if="currentYear === 2025" class="bg-muted/50 flex-1 rounded-xl p-2 text-center">
Glowing Fiesta 2025 <span class="text-muted-foreground">({{ config.public.appVersion }})</span>
</div>
<div v-else class="bg-muted/50 flex-1 rounded-xl p-2 text-center">
Glowing Fiesta 2025 - {{ currentYear }} <span class="text-muted-foreground">({{ config.public.appVersion }})</span>
</div>
</footer>
</SidebarInset>
</SidebarProvider>

View file

@ -1,23 +1,10 @@
<script setup lang="ts">
import { computed } from "vue";
import {
BadgeCheck,
Bell,
ChevronRight,
ChevronsUpDown,
CreditCard,
BookOpen,
HandCoins,
LogOut,
Settings2,
Sparkles,
SquareTerminal,
} from "lucide-vue-next";
import { BookOpen, ChevronRight, HandCoins, Settings2, SquareTerminal } from "lucide-vue-next";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarHeader,
SidebarMenu,
@ -27,23 +14,14 @@ import {
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
useSidebar,
type SidebarProps,
} from "~/components/ui/sidebar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/components/ui/collapsible";
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
import AppSidebarFooter from "~/layouts/default/SidebarFooter.vue";
import type { User } from "better-auth";
const data = {
user: {
@ -99,6 +77,7 @@ interface NavItem {
interface SidebarLayoutProps extends /* @vue-ignore */ SidebarProps {
navItems?: NavItem[];
user?: User | null | undefined;
}
const props = withDefaults(defineProps<SidebarLayoutProps>(), {
@ -106,8 +85,6 @@ const props = withDefaults(defineProps<SidebarLayoutProps>(), {
});
const navMain = computed(() => props.navItems || data.navMain);
const { isMobile } = useSidebar();
</script>
<template>
@ -169,76 +146,7 @@ const { isMobile } = useSidebar();
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar class="h-8 w-8 rounded-lg">
<AvatarImage :src="data.user.avatar" :alt="data.user.name" />
<AvatarFallback class="rounded-lg"> LB </AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{{ data.user.name }}</span>
<span class="truncate text-xs">{{ data.user.email }}</span>
</div>
<ChevronsUpDown class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
class="w-[--reka-dropdown-menu-trigger-width] min-w-56 rounded-lg"
:side="isMobile ? 'bottom' : 'right'"
align="end"
:side-offset="4"
>
<DropdownMenuLabel class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar class="h-8 w-8 rounded-lg">
<AvatarImage :src="data.user.avatar" :alt="data.user.name" />
<AvatarFallback class="rounded-lg">LB</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ data.user.name }}</span>
<span class="truncate text-xs">{{ data.user.email }}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<Sparkles />
Upgrade to Pro
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<BadgeCheck />
Account
</DropdownMenuItem>
<DropdownMenuItem>
<CreditCard />
Billing
</DropdownMenuItem>
<DropdownMenuItem>
<Bell />
Notifications
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
<LogOut />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
<AppSidebarFooter :user="user" />
<SidebarRail></SidebarRail>
</Sidebar>

View file

@ -0,0 +1,149 @@
<script setup lang="ts">
import { computed } from "vue";
import { SidebarFooter, SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "~/components/ui/sidebar";
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { BadgeCheck, Bell, ChevronsUpDown, CreditCard, LogIn, LogOut } from "lucide-vue-next";
import type { User } from "better-auth";
const { isMobile } = useSidebar();
const props = defineProps<{ user?: User | null | undefined }>();
const userInititials = computed(() => {
if (!props.user) return "";
return props.user.name
.split(" ")
.map((name) => name.charAt(0).toUpperCase())
.join("");
});
const handleLogout = () => {
navigateTo("/member/auth/logout");
};
const handleLogin = () => {
navigateTo("/member/auth/login");
};
</script>
<template>
<ClientOnly>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu v-if="user">
<DropdownMenuTrigger as-child>
<SidebarMenuButton
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar class="h-8 w-8 rounded-lg">
<AvatarImage :src="user?.image || ''" :alt="user?.name" />
<AvatarFallback class="rounded-lg">{{ userInititials }}</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{{ user?.name }}</span>
<span class="truncate text-xs">{{ user?.email }}</span>
</div>
<ChevronsUpDown class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
class="w-[--reka-dropdown-menu-trigger-width] min-w-56 rounded-lg"
:side="isMobile ? 'bottom' : 'right'"
align="end"
:side-offset="4"
>
<DropdownMenuLabel class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar class="h-8 w-8 rounded-lg">
<AvatarImage :src="user?.image || ''" :alt="user?.name" />
<AvatarFallback class="rounded-lg">{{ userInititials }}</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ user.name }}</span>
<span class="truncate text-xs">{{ user.email }}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<BadgeCheck />
Account
</DropdownMenuItem>
<DropdownMenuItem>
<CreditCard />
Billing
</DropdownMenuItem>
<DropdownMenuItem>
<Bell />
Notifications
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem @click="handleLogout">
<LogOut />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu v-else>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar class="h-8 w-8 rounded-lg">
<AvatarImage src="/images/human.png" alt="Anonymous" />
<AvatarFallback class="rounded-lg">Anon</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">Anonymous</span>
<span class="truncate text-xs">No email</span>
</div>
<ChevronsUpDown class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
class="w-[--reka-dropdown-menu-trigger-width] min-w-56 rounded-lg"
:side="isMobile ? 'bottom' : 'right'"
align="end"
:side-offset="4"
>
<DropdownMenuLabel class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar class="h-8 w-8 rounded-lg">
<AvatarImage src="/images/human.png" alt="Anonymous" />
<AvatarFallback class="rounded-lg">Anon</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">Anonymous</span>
<span class="truncate text-xs">No email</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem @click="handleLogin">
<LogIn />
Log in
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</ClientOnly>
</template>

View file

@ -0,0 +1,11 @@
<script setup lang="ts">
import SignupForm from "@/components/SignupForm.vue";
</script>
<template>
<div class="bg-muted/50 min-h-screen flex-1 rounded-xl md:min-h-min flex items-center justify-center gap-2">
<div class="flex w-full max-w-sm flex-col gap-6">
<SignupForm />
</div>
</div>
</template>

View file

@ -0,0 +1,11 @@
<script setup lang="ts">
import LoginForm from "@/components/LoginForm.vue";
</script>
<template>
<div class="bg-muted/50 min-h-screen flex-1 rounded-xl md:min-h-min flex items-center justify-center gap-2">
<div class="flex w-full max-w-sm flex-col gap-6">
<LoginForm />
</div>
</div>
</template>

View file

@ -0,0 +1,35 @@
<script setup lang="ts">
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldDescription } from "@/components/ui/field";
import { useAuthStore } from "~/stores/auth";
const authStore = useAuthStore();
await authStore.init();
</script>
<template>
<div class="bg-muted/50 min-h-screen flex-1 rounded-xl md:min-h-min flex items-center justify-center gap-2">
<div class="flex w-full max-w-sm flex-col gap-6">
<Card>
<CardHeader class="text-center">
<CardTitle class="text-xl">Logout</CardTitle>
<CardDescription>Are you sure you want to logout?</CardDescription>
</CardHeader>
<CardContent>
<form autocomplete="off" @submit.prevent="authStore.signOut()">
<Field>
<Button type="submit" variant="destructive">Logout</Button>
</Field>
</form>
</CardContent>
</Card>
<FieldDescription class="px-6 text-center">
<NuxtLink to="/"><Button variant="link">Home</Button></NuxtLink>
<NuxtLink to="/"><Button variant="link">Terms of Service</Button></NuxtLink>
<NuxtLink to="/"><Button variant="link">Privacy Policy</Button></NuxtLink>
</FieldDescription>
</div>
</div>
</template>

45
app/stores/auth.ts Normal file
View file

@ -0,0 +1,45 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { createAuthClient } from "better-auth/vue";
const authClient = createAuthClient();
export const useAuthStore = defineStore("useAuthStore", () => {
const session = ref<Awaited<ReturnType<typeof authClient.useSession>> | null>(null);
const lastError = ref<string | undefined>(undefined);
async function init() {
const data = await authClient.useSession(useFetch);
session.value = data;
lastError.value = undefined;
}
const user = computed(() => session.value?.data?.user);
const loading = computed(() => session.value?.isPending);
async function signIn(email: string, password: string) {
const { error } = await authClient.signIn.email({
email,
password,
callbackURL: "/",
});
if (error) {
lastError.value = error.message;
}
}
async function signOut() {
await authClient.signOut({});
navigateTo("/");
}
return {
init,
lastError,
loading,
signIn,
signOut,
user,
};
});

13
drizzle.config.ts Normal file
View file

@ -0,0 +1,13 @@
import { defineConfig } from "drizzle-kit";
import env from "./shared/utils/env";
export default defineConfig({
out: "./shared/utils/db/migrations",
schema: "./shared/utils/db/schema/index.ts",
casing: "snake_case",
dialect: "postgresql",
dbCredentials: {
url: env.DATABASE_URL,
},
});

View file

@ -1,8 +1,10 @@
// @ts-check
import withNuxt from "./.nuxt/eslint.config.mjs";
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
import nodePlugin from "eslint-plugin-n";
export default withNuxt(eslintPluginPrettierRecommended, {
plugins: { node: nodePlugin },
files: ["**/*.{ts,js,vue}"],
rules: {
// Vue rules
@ -13,5 +15,8 @@ export default withNuxt(eslintPluginPrettierRecommended, {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-require-imports": "off",
"@typescript-eslint/no-unused-vars": "warn",
// Node rules
"node/no-process-env": "error",
},
});

View file

@ -1,8 +1,18 @@
import { readFileSync } from "fs";
import { resolve } from "path";
import tailwindcss from "@tailwindcss/vite";
import "./shared/utils/env";
const packageJsonContent = JSON.parse(readFileSync(resolve(__dirname, "package.json"), "utf-8"));
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: "2025-07-15",
runtimeConfig: {
public: {
appVersion: packageJsonContent.version,
},
},
app: {
head: {
htmlAttrs: {
@ -22,7 +32,7 @@ export default defineNuxtConfig({
plugins: [tailwindcss()],
build: { sourcemap: false },
},
modules: ["@nuxt/eslint", "shadcn-nuxt", "@vueuse/nuxt"],
modules: ["@nuxt/eslint", "shadcn-nuxt", "@vueuse/nuxt", "@pinia/nuxt"],
shadcn: {
prefix: "",
componentDir: "~/components/ui",

2709
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -15,37 +15,55 @@
"type-check": "vue-tsc --noEmit",
"vitest": "vitest",
"test": "vitest run",
"coverage": "vitest run --coverage"
"coverage": "vitest run --coverage",
"prepare": "husky"
},
"dependencies": {
"@pinia/nuxt": "^0.11.3",
"better-auth": "^1.4.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.1",
"lucide-vue-next": "^0.562.0",
"nuxt": "^4.1.3",
"reka-ui": "^2.6.1",
"pg": "^8.16.3",
"pinia": "^3.0.4",
"reka-ui": "^2.7.0",
"shadcn-nuxt": "^2.4.3",
"tailwind-merge": "^3.4.0",
"uuid": "^13.0.0",
"vue": "^3.5.22",
"vue-router": "^4.6.3"
"vue-router": "^4.6.3",
"vue-sonner": "^2.0.9",
"zod": "^4.2.1"
},
"devDependencies": {
"@nuxt/eslint": "^1.9.0",
"@tailwindcss/vite": "^4.1.18",
"@types/pg": "^8.16.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vitest/coverage-v8": "^4.0.1",
"@vue/test-utils": "^2.4.6",
"@vueuse/core": "^14.1.0",
"@vueuse/nuxt": "^14.1.0",
"drizzle-kit": "^0.31.8",
"eslint": "^9.38.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-n": "^17.23.1",
"eslint-plugin-prettier": "^5.5.4",
"happy-dom": "^20.0.8",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"prettier": "^3.6.2",
"sass-embedded": "^1.93.3",
"tailwindcss": "^4.1.18",
"tsx": "^4.21.0",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vitest": "^4.0.1",
"vue-tsc": "^3.1.8"
},
"lint-staged": {
"*": "npm run lint"
}
}

BIN
public/images/human.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

5
server/api/[...auth].ts Normal file
View file

@ -0,0 +1,5 @@
import { auth } from "~~/shared/utils/auth";
export default defineEventHandler((event) => {
return auth.handler(toWebRequest(event));
});

View file

@ -0,0 +1,3 @@
import { createAuthClient } from "better-auth/vue";
export const authClient = createAuthClient();

18
shared/utils/auth.ts Normal file
View file

@ -0,0 +1,18 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { v7 as uuidv7 } from "uuid";
import db from "./db/index";
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
}),
advanced: {
database: {
generateId: () => uuidv7(),
},
},
emailAndPassword: {
enabled: true,
},
});

15
shared/utils/db/index.ts Normal file
View file

@ -0,0 +1,15 @@
// Global database connection
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import env from "../env";
import * as schema from "./schema";
const pool = new Pool({
connectionString: env.DATABASE_URL,
});
const db = drizzle({ client: pool, casing: "snake_case", schema });
export default db;

View file

@ -0,0 +1,62 @@
CREATE TABLE "account" (
"id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" uuid NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "session" (
"id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" uuid NOT NULL,
CONSTRAINT "session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean DEFAULT false NOT NULL,
"image" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "verification" (
"id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "member_data" (
"id" uuid PRIMARY KEY NOT NULL,
"country" text,
"info" jsonb,
"address" jsonb,
"billing" jsonb
);
--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "member_data" ADD CONSTRAINT "member_data_id_user_id_fk" FOREIGN KEY ("id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");

View file

@ -0,0 +1,435 @@
{
"id": "150f8caf-a5d3-4a62-8431-9d874384b93a",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "uuidv7()"
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"account_userId_idx": {
"name": "account_userId_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "uuidv7()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"session_userId_idx": {
"name": "session_userId_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "uuidv7()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "uuidv7()"
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"verification_identifier_idx": {
"name": "verification_identifier_idx",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.member_data": {
"name": "member_data",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true
},
"country": {
"name": "country",
"type": "text",
"primaryKey": false,
"notNull": false
},
"info": {
"name": "info",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"address": {
"name": "address",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"billing": {
"name": "billing",
"type": "jsonb",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"member_data_id_user_id_fk": {
"name": "member_data_id_user_id_fk",
"tableFrom": "member_data",
"tableTo": "user",
"columnsFrom": [
"id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1766830994761,
"tag": "0000_create_initial_tables",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,106 @@
import { v7 as uuidv7 } from "uuid";
import { relations, sql } from "drizzle-orm";
import { boolean, index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: uuid()
.primaryKey()
.default(sql`uuid_v7()`)
.$defaultFn(() => uuidv7()),
name: text().notNull(),
email: text().notNull().unique(),
emailVerified: boolean().default(false).notNull(),
image: text(),
createdAt: timestamp().defaultNow().notNull(),
updatedAt: timestamp()
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});
export const session = pgTable(
"session",
{
id: uuid()
.primaryKey()
.default(sql`uuid_v7()`)
.$defaultFn(() => uuidv7()),
expiresAt: timestamp().notNull(),
token: text().notNull().unique(),
createdAt: timestamp().defaultNow().notNull(),
updatedAt: timestamp()
.$onUpdate(() => new Date())
.notNull(),
ipAddress: text(),
userAgent: text(),
userId: uuid()
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => [index("session_userId_idx").on(table.userId)]
);
export const account = pgTable(
"account",
{
id: uuid()
.primaryKey()
.default(sql`uuid_v7()`)
.$defaultFn(() => uuidv7()),
accountId: text().notNull(),
providerId: text().notNull(),
userId: uuid()
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text(),
refreshToken: text(),
idToken: text(),
accessTokenExpiresAt: timestamp(),
refreshTokenExpiresAt: timestamp(),
scope: text(),
password: text(),
createdAt: timestamp().defaultNow().notNull(),
updatedAt: timestamp()
.$onUpdate(() => new Date())
.notNull(),
},
(table) => [index("account_userId_idx").on(table.userId)]
);
export const verification = pgTable(
"verification",
{
id: uuid()
.primaryKey()
.default(sql`uuid_v7()`)
.$defaultFn(() => uuidv7()),
identifier: text().notNull(),
value: text().notNull(),
expiresAt: timestamp().notNull(),
createdAt: timestamp().defaultNow().notNull(),
updatedAt: timestamp()
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
},
(table) => [index("verification_identifier_idx").on(table.identifier)]
);
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
}));
export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, {
fields: [session.userId],
references: [user.id],
}),
}));
export const accountRelations = relations(account, ({ one }) => ({
user: one(user, {
fields: [account.userId],
references: [user.id],
}),
}));

View file

@ -0,0 +1,2 @@
export * from "./auth";
export * from "./memberData";

View file

@ -0,0 +1,21 @@
import { relations } from "drizzle-orm";
import { jsonb, pgTable, text, uuid } from "drizzle-orm/pg-core";
import { user } from "./auth";
export const memberData = pgTable("member_data", {
id: uuid()
.primaryKey()
.references(() => user.id, { onDelete: "cascade" }),
country: text(),
info: jsonb(),
address: jsonb(),
billing: jsonb(),
});
export const memberDataRelations = relations(memberData, ({ one }) => ({
user: one(user, {
fields: [memberData.id],
references: [user.id],
}),
}));

13
shared/utils/env.ts Normal file
View file

@ -0,0 +1,13 @@
import { z } from "zod";
const EnvSchema = z.object({
NODE_ENV: z.string(),
DATABASE_URL: z.string(),
BETTER_AUTH_SECRET: z.string(),
BETTER_AUTH_URL: z.string(),
});
export type EnvSchema = z.infer<typeof EnvSchema>;
// eslint-disable-next-line node/no-process-env
export default EnvSchema.parse(process.env);

View file

@ -5,9 +5,9 @@ sonar.projectKey=GF
sonar.projectName=Glowing Fiesta
sonar.projectVersion=1.0.0
sonar.sourceEncoding=UTF-8
sonar.sources=app, tests
sonar.inclusions=app/**/*.ts, app/**/*.js, app/**/*.vue, app/**/*.css, app/**/*.scss, tests/**/*.test.ts
sonar.exclusions=**/node_modules/**, **/coverage/**, app/components/ui/**, *.config.ts
sonar.sources=app, server, shared, tests
sonar.inclusions=app/**/*.ts, app/**/*.js, app/**/*.vue, app/**/*.css, app/**/*.scss, server/**/*.ts, shared/**/*.ts, tests/**/*.test.ts
sonar.exclusions=**/node_modules/**, **/coverage/**, app/components/ui/**, shared/utils/db/**, *.config.ts
sonar.coverage.exclusions=tests/**, app/components/ui/**, *.config.ts
sonar.javascript.lcov.reportPaths=coverage/lcov.info
# sonar.testExecutionReportPaths=coverage/sonar-report.xml

View file

@ -1,38 +1,89 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { mount } from "@vue/test-utils";
import { afterEach, describe, expect, it, vi, beforeAll, afterAll } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import DefaultLayout from "~/layouts/Default.vue";
vi.mock("#app", () => ({
useRuntimeConfig: () => ({
public: {
appVersion: "1.0.1",
},
}),
}));
vi.mock("~/stores/auth", () => ({
useAuthStore: () => ({
init: vi.fn(),
user: null,
}),
}));
describe("Default.vue", () => {
beforeAll(() => {
const shouldSuppress = (args: string[]) => {
const msg = args.join(" ");
return msg.includes("<Suspense> is an experimental feature");
};
const spyMethods = ["warn", "error", "log", "info"] as const;
for (const method of spyMethods) {
const original = console[method];
vi.spyOn(console, method).mockImplementation((...args) => {
if (shouldSuppress(args)) return;
original(...args);
});
}
});
afterAll(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
it("loads without crashing", () => {
const wrapper = mount(DefaultLayout);
it("loads without crashing", async () => {
const wrapper = mount({
components: { DefaultLayout },
template: "<Suspense><DefaultLayout /></Suspense>",
});
await flushPromises(); // Wait for async setup
expect(wrapper.exists()).toBe(true);
});
describe("Footer", () => {
it("footer is displayed", () => {
const wrapper = mount(DefaultLayout);
it("footer is displayed", async () => {
const wrapper = mount({
components: { DefaultLayout },
template: "<Suspense><DefaultLayout /></Suspense>",
});
await flushPromises();
const footer = wrapper.find("[data-testid='footer']");
expect(footer.exists()).toBe(true);
});
it("footer shows only 2025 when current year is 2025", () => {
it("footer shows only 2025 when current year is 2025", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2025, 0, 1));
const wrapper = mount(DefaultLayout);
const wrapper = mount({
components: { DefaultLayout },
template: "<Suspense><DefaultLayout /></Suspense>",
});
await flushPromises();
const footer = wrapper.find("[data-testid='footer']");
expect(footer.text()).toBe("Glowing Fiesta 2025");
expect(footer.text()).toBe("Glowing Fiesta 2025 (1.0.1)");
});
it("footer shows range when current year is not 2025", () => {
it("footer shows range when current year is not 2025", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2069, 0, 1));
const wrapper = mount(DefaultLayout);
const wrapper = mount({
components: { DefaultLayout },
template: "<Suspense><DefaultLayout /></Suspense>",
});
await flushPromises();
const footer = wrapper.find("[data-testid='footer']");
expect(footer.text()).toBe("Glowing Fiesta 2025 - 2069");
expect(footer.text()).toBe("Glowing Fiesta 2025 - 2069 (1.0.1)");
});
});
});

View file

@ -1,5 +1,5 @@
import { mount } from "@vue/test-utils";
import { describe, expect, it, vi } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { describe, expect, it, vi, beforeAll, afterAll } from "vitest";
import SidebarLayout from "~/layouts/default/Sidebar.vue";
import { ref } from "vue";
import type * as SidebarUI from "~/components/ui/sidebar";
@ -8,6 +8,20 @@ const { useSidebarMock } = vi.hoisted(() => ({
useSidebarMock: vi.fn(),
}));
// ... (existing mocks)
vi.mock("~/stores/auth", () => ({
useAuthStore: () => ({
user: {
name: "Liviu",
email: "x.liviu@gmail.com",
image: "avatar.png",
},
signOut: vi.fn(),
init: vi.fn(),
}),
}));
// Mock the UI components and hook
vi.mock("~/components/ui/sidebar", async (importOriginal) => {
const actual = await importOriginal<typeof SidebarUI>();
@ -99,6 +113,7 @@ vi.mock("lucide-vue-next", () => {
CreditCard: MockIcon,
BookOpen: MockIcon,
HandCoins: MockIcon,
LogIn: MockIcon,
LogOut: MockIcon,
Settings2: MockIcon,
Sparkles: MockIcon,
@ -107,7 +122,26 @@ vi.mock("lucide-vue-next", () => {
});
describe("SidebarLayout", () => {
it("renders the header correctly", () => {
beforeAll(() => {
const shouldSuppress = (args: string[]) => {
const msg = args.join(" ");
return msg.includes("<Suspense> is an experimental feature");
};
const spyMethods = ["warn", "error", "log", "info"] as const;
for (const method of spyMethods) {
const original = console[method];
vi.spyOn(console, method).mockImplementation((...args) => {
if (shouldSuppress(args)) return;
original(...args);
});
}
});
afterAll(() => {
vi.restoreAllMocks();
});
it("renders the header correctly", async () => {
useSidebarMock.mockReturnValue({
isMobile: ref(false),
state: ref("expanded"),
@ -115,17 +149,41 @@ describe("SidebarLayout", () => {
setOpenMobile: vi.fn(),
});
const wrapper = mount(SidebarLayout, {
props: {
collapsible: "icon",
const wrapper = mount({
components: { SidebarLayout },
template: "<Suspense><SidebarLayout v-bind='props' /></Suspense>",
setup() {
return {
props: {
collapsible: "icon",
},
};
},
});
await flushPromises();
expect(wrapper.text()).toContain("Glowing Fiesta");
expect(wrapper.text()).toContain("v1.0.0");
});
it("renders sidebar content correctly", () => {
const wrapper = mount(SidebarLayout);
it("renders sidebar content correctly", async () => {
const user = {
name: "Liviu",
email: "x.liviu@gmail.com",
image: "avatar.png",
id: "123",
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
};
const wrapper = mount({
components: { SidebarLayout },
template: "<Suspense><SidebarLayout :user='user' /></Suspense>",
setup() {
return { user };
},
});
await flushPromises();
const text = wrapper.text();
// Navigation groups
@ -144,24 +202,31 @@ describe("SidebarLayout", () => {
expect(text).toContain("Get Started");
});
it("does not render icon if item.icon is missing", () => {
const wrapper = mount(SidebarLayout, {
props: {
navItems: [
{
title: "No Icon Item",
url: "#",
items: [],
it("does not render icon if item.icon is missing", async () => {
const wrapper = mount({
components: { SidebarLayout },
template: "<Suspense><SidebarLayout v-bind='props' /></Suspense>",
setup() {
return {
props: {
navItems: [
{
title: "No Icon Item",
url: "#",
items: [],
},
],
},
],
};
},
});
await flushPromises();
expect(wrapper.text()).toContain("No Icon Item");
expect(wrapper.find('[data-testid="sidebar-icon"]').exists()).toBe(false);
});
it("renders correctly in mobile view", () => {
it("renders correctly in mobile view", async () => {
useSidebarMock.mockReturnValue({
isMobile: ref(true),
state: ref("expanded"),
@ -169,7 +234,24 @@ describe("SidebarLayout", () => {
setOpenMobile: vi.fn(),
});
const wrapper = mount(SidebarLayout);
const user = {
name: "Liviu",
email: "x.liviu@gmail.com",
image: "avatar.png",
id: "123",
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
};
const wrapper = mount({
components: { SidebarLayout },
template: "<Suspense><SidebarLayout :user='user' /></Suspense>",
setup() {
return { user };
},
});
await flushPromises();
// When isMobile is true, it renders a Sheet.
// Since we mocked Sheet components as simple divs with slots, the content should still be present.

View file

@ -0,0 +1,336 @@
import { mount } from "@vue/test-utils";
import { describe, expect, it, vi } from "vitest";
import SidebarFooterComponent from "~/layouts/default/SidebarFooter.vue";
import type * as SidebarUI from "~/components/ui/sidebar";
const { useSidebarMock } = vi.hoisted(() => ({
useSidebarMock: vi.fn(),
}));
// Mock navigateTo
const navigateToMock = vi.fn();
vi.stubGlobal("navigateTo", navigateToMock);
// Mock UI components
vi.mock("~/components/ui/sidebar", async (importOriginal) => {
const actual = await importOriginal<typeof SidebarUI>();
const { ref } = await import("vue");
const MockComponent = {
template: "<div><slot /></div>",
inheritAttrs: false,
};
useSidebarMock.mockReturnValue({
isMobile: ref(false), // Ensure isMobile is a ref
state: ref("expanded"),
openMobile: ref(false),
setOpenMobile: vi.fn(),
});
return {
...actual,
SidebarFooter: MockComponent,
SidebarMenu: MockComponent,
SidebarMenuItem: MockComponent,
SidebarMenuButton: MockComponent,
useSidebar: useSidebarMock,
};
});
vi.mock("~/components/ui/avatar", () => {
const MockComponent = { template: "<div><slot /></div>" };
return {
Avatar: MockComponent,
AvatarFallback: MockComponent,
AvatarImage: MockComponent,
};
});
vi.mock("~/components/ui/dropdown-menu", () => {
const MockComponent = { template: "<div><slot /></div>" };
const MockContent = {
template: '<div data-testid="dropdown-content"><slot /></div>',
name: "DropdownMenuContent",
};
const DropdownMenuItem = {
name: "DropdownMenuItem",
template: '<div data-testid="dropdown-item" @click="$emit(\'click\')"><slot /></div>',
emits: ["click"],
};
return {
DropdownMenu: MockComponent,
DropdownMenuTrigger: MockComponent,
DropdownMenuContent: MockContent,
DropdownMenuGroup: MockComponent,
DropdownMenuItem: DropdownMenuItem,
DropdownMenuLabel: MockComponent,
DropdownMenuSeparator: MockComponent,
};
});
vi.mock("lucide-vue-next", () => {
const MockIcon = { template: '<svg class="lucide-mock" />' };
return {
BadgeCheck: MockIcon,
Bell: MockIcon,
ChevronsUpDown: MockIcon,
CreditCard: MockIcon,
LogIn: MockIcon,
LogOut: MockIcon,
};
});
describe("SidebarFooter.vue", () => {
const user = {
name: "Liviu Test",
email: "test@example.com",
image: "avatar.png",
id: "123",
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
};
it("renders user information correctly when user is provided", () => {
const wrapper = mount(SidebarFooterComponent, {
props: { user },
global: {
stubs: {
ClientOnly: { template: "<div><slot /></div>" },
},
},
});
expect(wrapper.text()).toContain("Liviu Test");
expect(wrapper.text()).toContain("test@example.com");
// Initials "Liviu Test" -> "LT"
expect(wrapper.text()).toContain("LT");
});
it("renders anonymous view correctly when user is not provided (null)", () => {
const wrapper = mount(SidebarFooterComponent, {
props: { user: null },
global: {
stubs: {
ClientOnly: { template: "<div><slot /></div>" },
},
},
});
expect(wrapper.text()).toContain("Anonymous");
expect(wrapper.text()).toContain("No email");
expect(wrapper.text()).toContain("Anon");
});
it("renders anonymous view correctly when user is undefined", () => {
const wrapper = mount(SidebarFooterComponent, {
props: { user: undefined },
global: {
stubs: {
ClientOnly: { template: "<div><slot /></div>" },
},
},
});
expect(wrapper.text()).toContain("Anonymous");
expect(wrapper.text()).toContain("No email");
expect(wrapper.text()).toContain("Anon");
});
it("renders 'Log out' option when user is logged in", () => {
const wrapper = mount(SidebarFooterComponent, {
props: { user },
global: {
stubs: {
ClientOnly: { template: "<div><slot /></div>" },
},
},
});
expect(wrapper.text()).toContain("Log out");
expect(wrapper.text()).not.toContain("Log in");
});
it("renders 'Log in' option when user is anonymous", () => {
const wrapper = mount(SidebarFooterComponent, {
props: { user: null },
global: {
stubs: {
ClientOnly: { template: "<div><slot /></div>" },
},
},
});
expect(wrapper.text()).toContain("Log in");
expect(wrapper.text()).not.toContain("Log out");
});
it("calls navigateTo('/member/auth/logout') when Log out is clicked", async () => {
const wrapper = mount(SidebarFooterComponent, {
props: { user },
global: {
stubs: {
ClientOnly: { template: "<div><slot /></div>" },
},
},
});
const logoutItem = wrapper.findAll('[data-testid="dropdown-item"]').find((item) => item.text().includes("Log out"));
expect(logoutItem).toBeDefined();
await logoutItem?.trigger("click");
expect(navigateToMock).toHaveBeenCalledWith("/member/auth/logout");
});
it("calls navigateTo('/member/auth/login') when Log in is clicked", async () => {
const wrapper = mount(SidebarFooterComponent, {
props: { user: null },
global: {
stubs: {
ClientOnly: { template: "<div><slot /></div>" },
},
},
});
const loginItem = wrapper.findAll('[data-testid="dropdown-item"]').find((item) => item.text().includes("Log in"));
expect(loginItem).toBeDefined();
await loginItem?.trigger("click");
expect(navigateToMock).toHaveBeenCalledWith("/member/auth/login");
});
it("computes initials correctly for single name", () => {
const singleNameUser = { ...user, name: "Liviu" };
const wrapper = mount(SidebarFooterComponent, {
props: { user: singleNameUser },
global: {
stubs: {
ClientOnly: { template: "<div><slot /></div>" },
},
},
});
// "Liviu" -> "L"
expect(wrapper.text()).toContain("L");
});
it("renders correctly when user has no image", () => {
const userNoImage = { ...user, image: null as unknown as string };
const wrapper = mount(SidebarFooterComponent, {
props: { user: userNoImage },
global: {
stubs: {
ClientOnly: { template: "<div><slot /></div>" },
},
},
});
expect(wrapper.text()).toContain("Liviu Test");
expect(wrapper.text()).toContain("test@example.com");
expect(wrapper.text()).toContain("LT");
});
it("sets side to 'bottom' when isMobile is true", () => {
const { ref } = require("vue");
useSidebarMock.mockReturnValue({
isMobile: ref(true),
state: ref("expanded"),
openMobile: ref(false),
setOpenMobile: vi.fn(),
});
const wrapper = mount(SidebarFooterComponent, {
props: { user },
global: {
stubs: {
ClientOnly: { template: "<div><slot /></div>" },
},
},
});
const dropdownContent = wrapper.findComponent({ name: "DropdownMenuContent" });
expect(dropdownContent.attributes("side")).toBe("bottom");
});
it("sets side to 'right' when isMobile is false", () => {
const { ref } = require("vue");
useSidebarMock.mockReturnValue({
isMobile: ref(false),
state: ref("expanded"),
openMobile: ref(false),
setOpenMobile: vi.fn(),
});
const wrapper = mount(SidebarFooterComponent, {
props: { user },
global: {
stubs: {
ClientOnly: { template: "<div><slot /></div>" },
},
},
});
const dropdownContent = wrapper.findComponent({ name: "DropdownMenuContent" });
expect(dropdownContent.attributes("side")).toBe("right");
});
it("sets side to 'bottom' when isMobile is true and user is anonymous", () => {
const { ref } = require("vue");
useSidebarMock.mockReturnValue({
isMobile: ref(true),
state: ref("expanded"),
openMobile: ref(false),
setOpenMobile: vi.fn(),
});
const wrapper = mount(SidebarFooterComponent, {
props: { user: null },
global: {
stubs: {
ClientOnly: { template: "<div><slot /></div>" },
},
},
});
const dropdownContent = wrapper.findComponent({ name: "DropdownMenuContent" });
expect(dropdownContent.attributes("side")).toBe("bottom");
});
it("sets side to 'right' when isMobile is false and user is anonymous", () => {
const { ref } = require("vue");
useSidebarMock.mockReturnValue({
isMobile: ref(false),
state: ref("expanded"),
openMobile: ref(false),
setOpenMobile: vi.fn(),
});
const wrapper = mount(SidebarFooterComponent, {
props: { user: null },
global: {
stubs: {
ClientOnly: { template: "<div><slot /></div>" },
},
},
});
const dropdownContent = wrapper.findComponent({ name: "DropdownMenuContent" });
expect(dropdownContent.attributes("side")).toBe("right");
});
it("returns empty string for userInititials when user is undefined (covering line 24)", () => {
const wrapper = mount(SidebarFooterComponent, {
props: { user: undefined },
global: {
stubs: {
ClientOnly: { template: "<div><slot /></div>" },
},
},
});
// Access the component's internal state/computed properties
expect((wrapper.vm as any).userInititials).toBe("");
});
});

View file

@ -0,0 +1,88 @@
import { mount, flushPromises } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach } from "vitest";
import CreateAccountPage from "~/pages/member/auth/create-account.vue";
// Mock auth client
const authMocks = vi.hoisted(() => ({
signUpEmail: vi.fn(),
}));
vi.mock("~~/shared/utils/auth-client", () => ({
authClient: {
signUp: {
email: authMocks.signUpEmail,
},
},
}));
// Mock UI components
vi.mock("@/components/ui/button", () => ({
Button: { template: "<button><slot /></button>" },
}));
vi.mock("@/components/ui/input", () => ({
Input: {
props: ["modelValue", "id", "type"],
emits: ["update:modelValue"],
template: `<input :id="id" :type="type" :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />`,
},
}));
vi.mock("@/components/ui/card", () => ({
Card: { template: "<div><slot /></div>" },
CardHeader: { template: "<div><slot /></div>" },
CardTitle: { template: "<h1><slot /></h1>" },
CardDescription: { template: "<p><slot /></p>" },
CardContent: { template: "<div><slot /></div>" },
}));
vi.mock("@/components/ui/field", () => ({
Field: { template: "<div><slot /></div>" },
FieldGroup: { template: "<div><slot /></div>" },
FieldLabel: { template: "<label><slot /></label>" },
FieldDescription: { template: "<span><slot /></span>" },
}));
describe("CreateAccountPage", () => {
beforeEach(() => {
vi.clearAllMocks();
authMocks.signUpEmail.mockResolvedValue({ data: {}, error: null });
});
it("renders the signup form correctly", async () => {
const wrapper = mount(CreateAccountPage);
// Wait for any async rendering if necessary
await flushPromises();
expect(wrapper.text()).toContain("Create your account");
expect(wrapper.find('input[type="email"]').exists()).toBe(true);
expect(wrapper.find('input[type="password"]').exists()).toBe(true);
});
it("submits the form with correct data", async () => {
const wrapper = mount(CreateAccountPage);
await flushPromises();
// Fill the form
const nameInput = wrapper.find('input[id="name"]');
const emailInput = wrapper.find('input[id="email"]');
const passwordInput = wrapper.find('input[id="password"]');
const confirmPasswordInput = wrapper.find('input[id="confirm-password"]');
await nameInput.setValue("Test User");
await emailInput.setValue("test@example.com");
await passwordInput.setValue("password123");
await confirmPasswordInput.setValue("password123");
// Submit
await wrapper.find("form").trigger("submit");
expect(authMocks.signUpEmail).toHaveBeenCalledWith({
name: "Test User",
email: "test@example.com",
password: "password123", // NOSONAR - Mocked value
});
});
});

View file

@ -0,0 +1,143 @@
import { mount, flushPromises } from "@vue/test-utils";
import { describe, expect, it, vi, beforeEach, beforeAll, afterAll } from "vitest";
import LoginPage from "~/pages/member/auth/login.vue";
// Mock the auth store
const authStoreMocks = vi.hoisted(() => ({
signIn: vi.fn(),
lastError: null,
}));
vi.mock("~/stores/auth", () => ({
useAuthStore: () => authStoreMocks,
}));
// Make useAuthStore available globally for the component
globalThis.useAuthStore = () => authStoreMocks;
// Mock UI components
vi.mock("@/components/ui/button", () => ({
Button: {
props: ["variant", "type"],
template: '<button :type="type"><slot /></button>',
},
}));
vi.mock("@/components/ui/input", () => ({
Input: {
props: ["modelValue", "id", "type", "placeholder", "required"],
emits: ["update:modelValue"],
template: `<input :id="id" :type="type" :placeholder="placeholder" :required="required" :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />`,
},
}));
vi.mock("@/components/ui/card", () => ({
Card: { template: "<div><slot /></div>" },
CardHeader: { template: "<div><slot /></div>" },
CardTitle: { template: "<h1><slot /></h1>" },
CardDescription: { template: "<p><slot /></p>" },
CardContent: { template: "<div><slot /></div>" },
}));
vi.mock("@/components/ui/field", () => ({
Field: {
props: ["variant"],
template: "<div><slot /></div>",
},
FieldGroup: { template: "<div><slot /></div>" },
FieldLabel: { template: "<label><slot /></label>" },
FieldDescription: { template: "<span><slot /></span>" },
}));
vi.mock("lucide-vue-next", () => ({
Frown: { template: "<svg></svg>" },
}));
describe("LoginPage", () => {
beforeAll(() => {
const shouldSuppress = (args: string[]) => {
const msg = args.join(" ");
return msg.includes("<Suspense> is an experimental feature");
};
const spyMethods = ["warn", "error", "log", "info"] as const;
for (const method of spyMethods) {
const original = console[method];
vi.spyOn(console, method).mockImplementation((...args) => {
if (shouldSuppress(args)) return;
original(...args);
});
}
});
afterAll(() => {
vi.restoreAllMocks();
});
beforeEach(() => {
vi.clearAllMocks();
authStoreMocks.lastError = null;
});
it("renders the login form correctly", async () => {
const wrapper = mount({
components: { LoginPage },
template: "<Suspense><LoginPage /></Suspense>",
});
await flushPromises();
expect(wrapper.text()).toContain("Login");
expect(wrapper.text()).toContain("Enter your email below to login");
expect(wrapper.find('input[type="email"]').exists()).toBe(true);
expect(wrapper.find('input[type="password"]').exists()).toBe(true);
expect(wrapper.text()).toContain("Don't have an account?");
expect(wrapper.text()).toContain("Create account");
});
it("submits the form with correct credentials", async () => {
const wrapper = mount({
components: { LoginPage },
template: "<Suspense><LoginPage /></Suspense>",
});
await flushPromises();
// Fill the form
const emailInput = wrapper.find('input[id="email"]');
const passwordInput = wrapper.find('input[id="password"]');
await emailInput.setValue("test@example.com");
await passwordInput.setValue("password123");
// Submit the form
await wrapper.find("form").trigger("submit");
expect(authStoreMocks.signIn).toHaveBeenCalledWith("test@example.com", "password123");
});
it("displays error message when lastError is set", async () => {
authStoreMocks.lastError = "Invalid credentials";
const wrapper = mount({
components: { LoginPage },
template: "<Suspense><LoginPage /></Suspense>",
});
await flushPromises();
expect(wrapper.text()).toContain("Invalid credentials");
});
it("contains links to Terms of Service and Privacy Policy", async () => {
const wrapper = mount({
components: { LoginPage },
template: "<Suspense><LoginPage /></Suspense>",
});
await flushPromises();
expect(wrapper.text()).toContain("Terms of Service");
expect(wrapper.text()).toContain("Privacy Policy");
});
});

View file

@ -0,0 +1,91 @@
import { mount, flushPromises } from "@vue/test-utils";
import { describe, expect, it, vi, beforeEach, beforeAll, afterAll } from "vitest";
import LogoutPage from "~/pages/member/auth/logout.vue";
// Mock the auth store
const mocks = vi.hoisted(() => ({
init: vi.fn(),
signOut: vi.fn(),
}));
vi.mock("~/stores/auth", () => ({
useAuthStore: () => ({
init: mocks.init,
signOut: mocks.signOut,
}),
}));
// Mock UI components
vi.mock("@/components/ui/button", () => ({
Button: {
template: "<button><slot /></button>",
},
}));
vi.mock("@/components/ui/card", () => ({
Card: { template: "<div><slot /></div>" },
CardContent: { template: "<div><slot /></div>" },
CardDescription: { template: "<div><slot /></div>" },
CardHeader: { template: "<div><slot /></div>" },
CardTitle: { template: "<div><slot /></div>" },
}));
vi.mock("@/components/ui/field", () => ({
Field: { template: "<div><slot /></div>" },
FieldDescription: { template: "<div><slot /></div>" },
}));
describe("LogoutPage", () => {
beforeAll(() => {
const shouldSuppress = (args: string[]) => {
const msg = args.join(" ");
return msg.includes("<Suspense> is an experimental feature");
};
const spyMethods = ["warn", "error", "log", "info"] as const;
for (const method of spyMethods) {
const original = console[method];
vi.spyOn(console, method).mockImplementation((...args) => {
if (shouldSuppress(args)) return;
original(...args);
});
}
});
afterAll(() => {
vi.restoreAllMocks();
});
beforeEach(() => {
vi.clearAllMocks();
});
it("renders correctly and calls init", async () => {
const wrapper = mount({
components: { LogoutPage },
template: "<Suspense><LogoutPage /></Suspense>",
});
await flushPromises();
expect(mocks.init).toHaveBeenCalled();
expect(wrapper.text()).toContain("Logout");
expect(wrapper.text()).toContain("Are you sure you want to logout?");
expect(wrapper.text()).toContain("Home");
});
it("calls signOut on form submit", async () => {
const wrapper = mount({
components: { LogoutPage },
template: "<Suspense><LogoutPage /></Suspense>",
});
await flushPromises();
const form = wrapper.find("form");
expect(form.exists()).toBe(true);
await form.trigger("submit");
expect(mocks.signOut).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,366 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { H3Event } from "h3";
// Mock the auth utility
const mocks = vi.hoisted(() => ({
authHandler: vi.fn(),
defineEventHandler: vi.fn((handler) => handler),
toWebRequest: vi.fn((event: H3Event) => {
// Create a mock Request object
const url = event.node.req.url || "/";
const method = event.node.req.method || "GET";
return new Request(`http://localhost${url}`, {
method,
headers: event.node.req.headers as HeadersInit,
});
}),
}));
vi.mock("~~/shared/utils/auth", () => ({
auth: {
handler: mocks.authHandler,
},
}));
// Mock H3 utilities
vi.mock("h3", async () => {
const actual = await vi.importActual<typeof import("h3")>("h3");
return {
...actual,
defineEventHandler: mocks.defineEventHandler,
toWebRequest: mocks.toWebRequest,
};
});
describe("Auth API Handler", () => {
let handler: any;
beforeEach(async () => {
vi.clearAllMocks();
// Set up global functions for Nuxt auto-imports
(globalThis as any).defineEventHandler = mocks.defineEventHandler;
(globalThis as any).toWebRequest = mocks.toWebRequest;
// Dynamically import the handler after mocks are set up
const module = await import("../../../server/api/[...auth]");
handler = module.default;
});
it("should be defined", () => {
expect(handler).toBeDefined();
expect(typeof handler).toBe("function");
});
it("should call auth.handler with converted web request", async () => {
// Mock H3Event
const mockEvent = {
node: {
req: {
method: "POST",
url: "/api/auth/sign-in",
headers: {
"content-type": "application/json",
},
},
res: {},
},
context: {},
} as unknown as H3Event;
// Mock the response from auth.handler
const mockResponse = new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "content-type": "application/json" },
});
mocks.authHandler.mockResolvedValue(mockResponse);
// Call the handler
const result = await handler(mockEvent);
// Verify auth.handler was called
expect(mocks.authHandler).toHaveBeenCalledTimes(1);
// Verify the result
expect(result).toBe(mockResponse);
});
it("should handle GET requests", async () => {
const mockEvent = {
node: {
req: {
method: "GET",
url: "/api/auth/session",
headers: {},
},
res: {},
},
context: {},
} as unknown as H3Event;
const mockResponse = new Response(JSON.stringify({ user: null }), {
status: 200,
headers: { "content-type": "application/json" },
});
mocks.authHandler.mockResolvedValue(mockResponse);
const result = await handler(mockEvent);
expect(mocks.authHandler).toHaveBeenCalledTimes(1);
expect(result).toBe(mockResponse);
});
it("should handle POST requests for sign-in", async () => {
const mockEvent = {
node: {
req: {
method: "POST",
url: "/api/auth/sign-in/email",
headers: {
"content-type": "application/json",
},
},
res: {},
},
context: {},
} as unknown as H3Event;
const mockResponse = new Response(
JSON.stringify({
user: {
id: "123",
email: "test@example.com",
name: "Test User",
},
session: { token: "abc123" },
}),
{
status: 200,
headers: { "content-type": "application/json" },
}
);
mocks.authHandler.mockResolvedValue(mockResponse);
const result = await handler(mockEvent);
expect(mocks.authHandler).toHaveBeenCalledTimes(1);
expect(result).toBe(mockResponse);
});
it("should handle POST requests for sign-up", async () => {
const mockEvent = {
node: {
req: {
method: "POST",
url: "/api/auth/sign-up/email",
headers: {
"content-type": "application/json",
},
},
res: {},
},
context: {},
} as unknown as H3Event;
const mockResponse = new Response(
JSON.stringify({
user: {
id: "456",
email: "newuser@example.com",
name: "New User",
},
session: { token: "xyz789" },
}),
{
status: 201,
headers: { "content-type": "application/json" },
}
);
mocks.authHandler.mockResolvedValue(mockResponse);
const result = await handler(mockEvent);
expect(mocks.authHandler).toHaveBeenCalledTimes(1);
expect(result).toBe(mockResponse);
});
it("should handle POST requests for sign-out", async () => {
const mockEvent = {
node: {
req: {
method: "POST",
url: "/api/auth/sign-out",
headers: {
"content-type": "application/json",
},
},
res: {},
},
context: {},
} as unknown as H3Event;
const mockResponse = new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "content-type": "application/json" },
});
mocks.authHandler.mockResolvedValue(mockResponse);
const result = await handler(mockEvent);
expect(mocks.authHandler).toHaveBeenCalledTimes(1);
expect(result).toBe(mockResponse);
});
it("should handle error responses from auth.handler", async () => {
const mockEvent = {
node: {
req: {
method: "POST",
url: "/api/auth/sign-in/email",
headers: {
"content-type": "application/json",
},
},
res: {},
},
context: {},
} as unknown as H3Event;
const mockErrorResponse = new Response(
JSON.stringify({
error: "Invalid credentials",
}),
{
status: 401,
headers: { "content-type": "application/json" },
}
);
mocks.authHandler.mockResolvedValue(mockErrorResponse);
const result = await handler(mockEvent);
expect(mocks.authHandler).toHaveBeenCalledTimes(1);
expect(result).toBe(mockErrorResponse);
});
it("should handle different HTTP methods", async () => {
const methods = ["GET", "POST", "PUT", "DELETE", "PATCH"];
for (const method of methods) {
vi.clearAllMocks();
const mockEvent = {
node: {
req: {
method,
url: "/api/auth/test",
headers: {},
},
res: {},
},
context: {},
} as unknown as H3Event;
const mockResponse = new Response(JSON.stringify({ method }), {
status: 200,
headers: { "content-type": "application/json" },
});
mocks.authHandler.mockResolvedValue(mockResponse);
const result = await handler(mockEvent);
expect(mocks.authHandler).toHaveBeenCalledTimes(1);
expect(result).toBe(mockResponse);
}
});
it("should convert H3Event to Web Request correctly", async () => {
const mockEvent = {
node: {
req: {
method: "POST",
url: "/api/auth/test",
headers: {
"content-type": "application/json",
authorization: "Bearer token123",
},
},
res: {},
},
context: {},
} as unknown as H3Event;
const mockResponse = new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "content-type": "application/json" },
});
mocks.authHandler.mockResolvedValue(mockResponse);
await handler(mockEvent);
// Verify that auth.handler was called with a Request object
expect(mocks.authHandler).toHaveBeenCalledTimes(1);
const callArg = mocks.authHandler.mock.calls[0][0];
// The argument should be a Request object (from toWebRequest conversion)
expect(callArg).toBeDefined();
});
it("should handle requests with query parameters", async () => {
const mockEvent = {
node: {
req: {
method: "GET",
url: "/api/auth/session?redirect=/dashboard",
headers: {},
},
res: {},
},
context: {},
} as unknown as H3Event;
const mockResponse = new Response(JSON.stringify({ user: null }), {
status: 200,
headers: { "content-type": "application/json" },
});
mocks.authHandler.mockResolvedValue(mockResponse);
const result = await handler(mockEvent);
expect(mocks.authHandler).toHaveBeenCalledTimes(1);
expect(result).toBe(mockResponse);
});
it("should handle requests with different content types", async () => {
const contentTypes = ["application/json", "application/x-www-form-urlencoded", "multipart/form-data"];
for (const contentType of contentTypes) {
vi.clearAllMocks();
const mockEvent = {
node: {
req: {
method: "POST",
url: "/api/auth/sign-in",
headers: {
"content-type": contentType,
},
},
res: {},
},
context: {},
} as unknown as H3Event;
const mockResponse = new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "content-type": "application/json" },
});
mocks.authHandler.mockResolvedValue(mockResponse);
const result = await handler(mockEvent);
expect(mocks.authHandler).toHaveBeenCalledTimes(1);
expect(result).toBe(mockResponse);
}
});
});

View file

@ -1,5 +1,13 @@
import { vi } from "vitest";
import { config } from "@vue/test-utils";
import * as vue from "vue";
(global as any).ref = vue.ref;
(global as any).reactive = vue.reactive;
(global as any).computed = vue.computed;
(global as any).watch = vue.watch;
(global as any).onMounted = vue.onMounted;
(global as any).onUnmounted = vue.onUnmounted;
Object.defineProperty(global, "import", {
value: {
@ -15,5 +23,6 @@ config.global.stubs = {
NuxtPage: true,
Divider: true,
NuxtRouteAnnouncer: true,
NuxtLink: { template: "<a><slot /></a>" },
NuxtLink: { template: "<a><slot /></a>", props: ["to"] },
ClientOnly: { template: "<div class='client-only'><slot /></div>" },
};

View file

@ -0,0 +1,133 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
// Mock uuid
const mockUuidv7 = vi.fn(() => "test-uuid-v7");
vi.mock("uuid", () => ({
v7: mockUuidv7,
}));
// Mock database
const mockDb = {
query: vi.fn(),
select: vi.fn(),
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
vi.mock("#shared/utils/db/index", () => ({
default: mockDb,
}));
// Mock better-auth
const mockBetterAuth = vi.fn((config) => ({
handler: vi.fn(),
api: vi.fn(),
config,
$Infer: {} as any,
}));
const mockDrizzleAdapter = vi.fn((db, options) => ({
db,
options,
type: "drizzle-adapter",
}));
vi.mock("better-auth", () => ({
betterAuth: mockBetterAuth,
}));
vi.mock("better-auth/adapters/drizzle", () => ({
drizzleAdapter: mockDrizzleAdapter,
}));
describe("auth utility", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should create betterAuth instance with correct configuration", async () => {
// Import the auth module (this will trigger the betterAuth call)
await import("#shared/utils/auth");
// Verify betterAuth was called
expect(mockBetterAuth).toHaveBeenCalledTimes(1);
// Get the configuration passed to betterAuth
const config = mockBetterAuth.mock.calls[0][0];
// Verify the configuration structure
expect(config).toBeDefined();
expect(config).toHaveProperty("database");
expect(config).toHaveProperty("advanced");
expect(config).toHaveProperty("emailAndPassword");
// Verify only emailAndPassword is configured
expect(config).not.toHaveProperty("oauth");
expect(config).not.toHaveProperty("magicLink");
expect(config).not.toHaveProperty("twoFactor");
// Verify nested properties
expect(config.advanced).toHaveProperty("database");
expect(config.advanced.database).toHaveProperty("generateId");
expect(config.emailAndPassword).toHaveProperty("enabled");
// Verify drizzleAdapter was called with correct arguments
expect(mockDrizzleAdapter).toHaveBeenCalledTimes(1);
expect(mockDrizzleAdapter).toHaveBeenCalledWith(mockDb, {
provider: "pg",
});
// Verify emailAndPassword is enabled
expect(config.emailAndPassword).toEqual({
enabled: true,
});
// Verify advanced.database.generateId is a function
expect(config.advanced).toBeDefined();
expect(config.advanced.database).toBeDefined();
expect(config.advanced.database.generateId).toBeTypeOf("function");
// Test the generateId function
const generatedId = config.advanced.database.generateId();
expect(mockUuidv7).toHaveBeenCalled();
expect(generatedId).toBe("test-uuid-v7");
// Get the drizzle adapter call
const dbInstance = mockDrizzleAdapter.mock.calls[0][0];
expect(dbInstance).toBe(mockDb);
});
it("should export auth instance with expected properties", async () => {
// Import the auth module
const { auth } = await import("#shared/utils/auth");
// Verify the auth instance has expected properties
expect(auth).toBeDefined();
expect(auth).toHaveProperty("handler");
expect(auth).toHaveProperty("api");
expect(auth).toHaveProperty("config");
});
describe("module exports", () => {
it("should export auth as named export", async () => {
// Import the auth module
const authModule = await import("#shared/utils/auth");
// Verify named export exists
expect(authModule).toHaveProperty("auth");
expect(authModule.auth).toBeDefined();
});
it("should not have default export", async () => {
// Import the auth module
const authModule = await import("#shared/utils/auth");
// Verify no default export (or it's the same as named export)
// In ES modules, default export would be authModule.default
// @ts-expect-error The description must be 10 characters or longer
expect(authModule.default).toBeUndefined();
});
});
});

446
tests/stores/auth.test.ts Normal file
View file

@ -0,0 +1,446 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { setActivePinia, createPinia } from "pinia";
import { useAuthStore } from "~/stores/auth";
// Mock better-auth/vue
const { mockUseSession, mockSignInEmail, mockSignOut } = vi.hoisted(() => ({
mockUseSession: vi.fn(),
mockSignInEmail: vi.fn(),
mockSignOut: vi.fn(),
}));
vi.mock("better-auth/vue", () => ({
createAuthClient: () => ({
useSession: mockUseSession,
signIn: {
email: mockSignInEmail,
},
signOut: mockSignOut,
}),
}));
// Mock navigateTo
const navigateToMock = vi.fn();
vi.stubGlobal("navigateTo", navigateToMock);
// Mock useFetch
const useFetchMock = vi.fn();
vi.stubGlobal("useFetch", useFetchMock);
describe("useAuthStore", () => {
beforeEach(() => {
// Create a fresh pinia instance for each test
setActivePinia(createPinia());
vi.clearAllMocks();
});
describe("init", () => {
it("should initialize session with user data", async () => {
const mockSessionData = {
data: {
user: {
id: "123",
name: "Test User",
email: "test@example.com",
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
image: "avatar.png",
},
session: {
id: "session-123",
userId: "123",
expiresAt: new Date(),
token: "token-123",
ipAddress: "127.0.0.1",
userAgent: "test-agent",
},
},
isPending: false,
error: null,
};
mockUseSession.mockResolvedValue(mockSessionData);
const store = useAuthStore();
await store.init();
expect(mockUseSession).toHaveBeenCalledWith(useFetchMock);
expect(store.user).toEqual(mockSessionData.data.user);
expect(store.loading).toBe(false);
expect(store.lastError).toBeUndefined();
});
it("should handle session with no user (logged out state)", async () => {
const mockSessionData = {
data: null,
isPending: false,
error: null,
};
mockUseSession.mockResolvedValue(mockSessionData);
const store = useAuthStore();
await store.init();
expect(mockUseSession).toHaveBeenCalledWith(useFetchMock);
expect(store.user).toBeUndefined();
expect(store.loading).toBe(false);
expect(store.lastError).toBeUndefined();
});
it("should clear lastError when init is called", async () => {
const mockSessionData = {
data: null,
isPending: false,
error: null,
};
mockUseSession.mockResolvedValue(mockSessionData);
const store = useAuthStore();
// Set an error first
store.lastError = "Previous error";
await store.init();
expect(store.lastError).toBeUndefined();
});
it("should handle pending session state", async () => {
const mockSessionData = {
data: null,
isPending: true,
error: null,
};
mockUseSession.mockResolvedValue(mockSessionData);
const store = useAuthStore();
await store.init();
expect(store.loading).toBe(true);
});
});
describe("signIn", () => {
it("should successfully sign in with valid credentials", async () => {
mockSignInEmail.mockResolvedValue({ error: null });
const store = useAuthStore();
await store.signIn("test@example.com", "password123");
expect(mockSignInEmail).toHaveBeenCalledWith({
email: "test@example.com",
password: "password123", // NOSONAR - Mocked value
callbackURL: "/",
});
expect(store.lastError).toBeUndefined();
});
it("should set lastError when sign in fails", async () => {
const errorMessage = "Invalid credentials";
mockSignInEmail.mockResolvedValue({
error: { message: errorMessage },
});
const store = useAuthStore();
await store.signIn("test@example.com", "wrongpassword");
expect(mockSignInEmail).toHaveBeenCalledWith({
email: "test@example.com",
password: "wrongpassword", // NOSONAR - Mocked value
callbackURL: "/",
});
expect(store.lastError).toBe(errorMessage);
});
it("should handle network errors during sign in", async () => {
const errorMessage = "Network error";
mockSignInEmail.mockResolvedValue({
error: { message: errorMessage },
});
const store = useAuthStore();
await store.signIn("test@example.com", "password123");
expect(store.lastError).toBe(errorMessage);
});
it("should clear previous error on successful sign in", async () => {
mockSignInEmail.mockResolvedValue({ error: null });
const store = useAuthStore();
store.lastError = "Previous error";
await store.signIn("test@example.com", "password123");
// Note: lastError is only set when there's an error, not cleared on success
// This test documents the current behavior
expect(store.lastError).toBe("Previous error");
});
});
describe("signOut", () => {
it("should call signOut and navigate to home", async () => {
mockSignOut.mockResolvedValue({});
const store = useAuthStore();
await store.signOut();
expect(mockSignOut).toHaveBeenCalledWith({});
expect(navigateToMock).toHaveBeenCalledWith("/");
});
it("should navigate to home even if signOut fails", async () => {
mockSignOut.mockRejectedValue(new Error("Sign out failed"));
const store = useAuthStore();
// The current implementation doesn't handle errors, so this will throw
await expect(store.signOut()).rejects.toThrow("Sign out failed");
// navigateTo is not called because the error is thrown before it
expect(navigateToMock).not.toHaveBeenCalled();
});
});
describe("computed properties", () => {
it("should return user from session data", async () => {
const mockUser = {
id: "123",
name: "Test User",
email: "test@example.com",
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
image: "avatar.png",
};
const mockSessionData = {
data: {
user: mockUser,
session: {
id: "session-123",
userId: "123",
expiresAt: new Date(),
token: "token-123",
ipAddress: "127.0.0.1",
userAgent: "test-agent",
},
},
isPending: false,
error: null,
};
mockUseSession.mockResolvedValue(mockSessionData);
const store = useAuthStore();
await store.init();
expect(store.user).toEqual(mockUser);
});
it("should return undefined when no user is logged in", async () => {
const mockSessionData = {
data: null,
isPending: false,
error: null,
};
mockUseSession.mockResolvedValue(mockSessionData);
const store = useAuthStore();
await store.init();
expect(store.user).toBeUndefined();
});
it("should return loading state from session", async () => {
const mockSessionData = {
data: null,
isPending: true,
error: null,
};
mockUseSession.mockResolvedValue(mockSessionData);
const store = useAuthStore();
await store.init();
expect(store.loading).toBe(true);
});
it("should return false for loading when session is loaded", async () => {
const mockSessionData = {
data: {
user: {
id: "123",
name: "Test User",
email: "test@example.com",
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
image: "avatar.png",
},
session: {
id: "session-123",
userId: "123",
expiresAt: new Date(),
token: "token-123",
ipAddress: "127.0.0.1",
userAgent: "test-agent",
},
},
isPending: false,
error: null,
};
mockUseSession.mockResolvedValue(mockSessionData);
const store = useAuthStore();
await store.init();
expect(store.loading).toBe(false);
});
});
describe("store state management", () => {
it("should maintain state across multiple operations", async () => {
const mockUser = {
id: "123",
name: "Test User",
email: "test@example.com",
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
image: "avatar.png",
};
const mockSessionData = {
data: {
user: mockUser,
session: {
id: "session-123",
userId: "123",
expiresAt: new Date(),
token: "token-123",
ipAddress: "127.0.0.1",
userAgent: "test-agent",
},
},
isPending: false,
error: null,
};
mockUseSession.mockResolvedValue(mockSessionData);
mockSignInEmail.mockResolvedValue({ error: null });
const store = useAuthStore();
// Initialize
await store.init();
expect(store.user).toEqual(mockUser);
// Sign in
await store.signIn("test@example.com", "password123");
expect(store.user).toEqual(mockUser); // User should still be there
// Sign out
mockSignOut.mockResolvedValue({});
await store.signOut();
expect(navigateToMock).toHaveBeenCalledWith("/");
});
it("should handle error state persistence", async () => {
const errorMessage = "Authentication failed";
mockSignInEmail.mockResolvedValue({
error: { message: errorMessage },
});
const store = useAuthStore();
// First failed sign in
await store.signIn("test@example.com", "wrongpassword");
expect(store.lastError).toBe(errorMessage);
// Error should persist
expect(store.lastError).toBe(errorMessage);
// Init should clear the error
mockUseSession.mockResolvedValue({
data: null,
isPending: false,
error: null,
});
await store.init();
expect(store.lastError).toBeUndefined();
});
});
describe("edge cases", () => {
it("should handle empty email and password", async () => {
mockSignInEmail.mockResolvedValue({ error: null });
const store = useAuthStore();
await store.signIn("", "");
expect(mockSignInEmail).toHaveBeenCalledWith({
email: "",
password: "",
callbackURL: "/",
});
});
it("should handle special characters in credentials", async () => {
mockSignInEmail.mockResolvedValue({ error: null });
const store = useAuthStore();
const specialEmail = "test+special@example.com";
const specialPassword = "p@ssw0rd!#$%"; // NOSONAR - Mocked value
await store.signIn(specialEmail, specialPassword);
expect(mockSignInEmail).toHaveBeenCalledWith({
email: specialEmail,
password: specialPassword,
callbackURL: "/",
});
});
it("should handle session data with missing user properties", async () => {
const mockSessionData = {
data: {
user: {
id: "123",
name: "Test User",
email: "test@example.com",
emailVerified: false,
createdAt: new Date(),
updatedAt: new Date(),
// image is optional and missing
},
session: {
id: "session-123",
userId: "123",
expiresAt: new Date(),
token: "token-123",
ipAddress: "127.0.0.1",
userAgent: "test-agent",
},
},
isPending: false,
error: null,
};
mockUseSession.mockResolvedValue(mockSessionData);
const store = useAuthStore();
await store.init();
expect(store.user).toBeDefined();
expect(store.user?.id).toBe("123");
expect(store.user?.image).toBeUndefined();
});
});
});

View file

@ -9,7 +9,9 @@
"~": ["./app"],
"~/*": ["./app/*"],
"@": ["./app"],
"@/*": ["./app/*"]
"@/*": ["./app/*"],
"#shared": ["./shared"],
"#shared/*": ["./shared/*"],
}
},
"include": ["./tests/**/*"]

View file

@ -35,6 +35,9 @@ export default defineConfig({
// Exclude UI components
"app/components/ui/**",
// Database schemas
"shared/utils/db/**",
],
},
name: "GFiesta",
@ -43,6 +46,9 @@ export default defineConfig({
alias: {
"~": fileURLToPath(new URL("./app", import.meta.url)),
"@": fileURLToPath(new URL("./app", import.meta.url)),
"#app": fileURLToPath(new URL("./.nuxt/types/imports.d.ts", import.meta.url)),
"~~": fileURLToPath(new URL("./", import.meta.url)),
"#shared": fileURLToPath(new URL("./shared", import.meta.url)),
},
},
});