Added login design

This commit is contained in:
Francisco Gaona
2025-11-25 18:58:49 +01:00
parent 484af68571
commit 150edfaf41
21 changed files with 9715 additions and 187 deletions

21
frontend/components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"typescript": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "assets/css/main.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"composables": "@/composables"
},
"registries": {}
}

View File

@@ -0,0 +1,122 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
const config = useRuntimeConfig()
const router = useRouter()
const tenantId = ref('123')
const email = ref('')
const password = ref('')
const loading = ref(false)
const error = ref('')
const handleLogin = async () => {
try {
loading.value = true
error.value = ''
const response = await fetch(`${config.public.apiBaseUrl}/api/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-tenant-id': tenantId.value,
},
body: JSON.stringify({
email: email.value,
password: password.value,
}),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.message || 'Login failed')
}
const data = await response.json()
// Store credentials
localStorage.setItem('tenantId', tenantId.value)
localStorage.setItem('token', data.access_token)
localStorage.setItem('user', JSON.stringify(data.user))
// Redirect to home
router.push('/')
} catch (e: any) {
error.value = e.message || 'Login failed'
} finally {
loading.value = false
}
}
</script>
<template>
<form @submit.prevent="handleLogin" class="flex flex-col gap-6">
<div class="flex flex-col items-center gap-2 text-center">
<h1 class="text-2xl font-bold">
Login to your account
</h1>
<p class="text-balance text-sm text-muted-foreground">
Enter your credentials below to login
</p>
</div>
<div v-if="error" class="p-3 bg-destructive/10 text-destructive rounded text-sm">
{{ error }}
</div>
<div class="grid gap-6">
<div class="grid gap-2">
<Label for="tenantId">Tenant ID</Label>
<Input
id="tenantId"
v-model="tenantId"
type="text"
placeholder="123"
required
/>
</div>
<div class="grid gap-2">
<Label for="email">Email</Label>
<Input
id="email"
v-model="email"
type="email"
placeholder="m@example.com"
required
/>
</div>
<div class="grid gap-2">
<div class="flex items-center">
<Label for="password">Password</Label>
<a
href="#"
class="ml-auto text-sm underline-offset-4 hover:underline"
>
Forgot your password?
</a>
</div>
<Input
id="password"
v-model="password"
type="password"
required
/>
</div>
<Button
type="submit"
class="w-full"
:disabled="loading"
>
{{ loading ? 'Logging in...' : 'Login' }}
</Button>
</div>
<div class="text-center text-sm">
Don't have an account?
<NuxtLink to="/register" class="underline underline-offset-4">
Sign up
</NuxtLink>
</div>
</form>
</template>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from "."
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from "."
interface Props {
variant?: ButtonVariants["variant"]
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
as?: any
asChild?: boolean
}
const props = withDefaults(defineProps<Props>(), {
as: "button",
asChild: false,
})
</script>
<template>
<Primitive
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>

View File

@@ -0,0 +1,38 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Button } from "./Button.vue"
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
"default": "h-9 px-4 py-2",
"xs": "h-7 rounded px-2",
"sm": "h-8 rounded-md px-3 text-xs",
"lg": "h-10 rounded-md px-8",
"icon": "h-9 w-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
:class="
cn(
'rounded-xl border bg-card text-card-foreground shadow',
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 :class="cn('p-6 pt-0', 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 :class="cn('text-sm text-muted-foreground', 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 :class="cn('flex items-center p-6 pt-0', 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 :class="cn('flex flex-col gap-y-1.5 p-6', props.class)">
<slot />
</div>
</template>

View File

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

View File

@@ -0,0 +1,6 @@
export { default as Card } from "./Card.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,24 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { useVModel } from "@vueuse/core"
import { cn } from "@/lib/utils"
const props = defineProps<{
defaultValue?: string | number
modelValue?: string | number
class?: HTMLAttributes["class"]
}>()
const emits = defineEmits<{
(e: "update:modelValue", payload: string | number): void
}>()
const modelValue = useVModel(props, "modelValue", emits, {
passive: true,
defaultValue: props.defaultValue,
})
</script>
<template>
<input v-model="modelValue" :class="cn('flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', props.class)">
</template>

View File

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

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Label } from "reka-ui"
import { cn } from "@/lib/utils"
interface Props {
for?: string
class?: HTMLAttributes["class"]
}
const props = defineProps<Props>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<Label
v-bind="delegatedProps"
:class="
cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
props.class,
)
"
>
<slot />
</Label>
</template>

View File

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

6
frontend/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -2,7 +2,22 @@
export default defineNuxtConfig({
devtools: { enabled: true },
modules: ['@nuxtjs/tailwindcss', '@nuxtjs/color-mode'],
modules: ['@nuxtjs/tailwindcss', '@nuxtjs/color-mode', 'shadcn-nuxt'],
shadcn: {
/**
* Prefix for all the imported component.
* @default "Ui"
*/
prefix: '',
/**
* Directory that the component lives in.
* Will respect the Nuxt aliases.
* @link https://nuxt.com/docs/api/nuxt-config#alias
* @default "@/components/ui"
*/
componentDir: '@/components/ui'
},
colorMode: {
classSuffix: '',
@@ -28,5 +43,18 @@ export default defineNuxtConfig({
strict: true,
},
vite: {
server: {
hmr: {
clientPort: 3001,
},
allowedHosts: [
'jupiter.routebox.co',
'localhost',
'127.0.0.1',
],
},
},
compatibilityDate: '2024-01-01',
})

9194
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -13,15 +13,17 @@
},
"dependencies": {
"@nuxtjs/tailwindcss": "^6.11.4",
"@vueuse/core": "^10.7.2",
"nuxt": "^3.10.0",
"vue": "^3.4.15",
"vue-router": "^4.2.5",
"radix-vue": "^1.4.1",
"@vueuse/core": "^10.11.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"lucide-vue-next": "^0.309.0",
"nuxt": "^3.10.0",
"radix-vue": "^1.4.1",
"reka-ui": "^2.6.0",
"shadcn-nuxt": "^2.3.3",
"tailwind-merge": "^2.2.1",
"lucide-vue-next": "^0.309.0"
"vue": "^3.4.15",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@nuxtjs/color-mode": "^3.3.2",

View File

@@ -1,110 +1,31 @@
<script setup lang="ts">
import { LayoutGrid } from 'lucide-vue-next'
import LoginForm from '@/components/LoginForm.vue'
</script>
<template>
<div class="min-h-screen bg-background flex items-center justify-center">
<div class="w-full max-w-md p-8 border rounded-lg bg-card">
<h1 class="text-3xl font-bold mb-6 text-center">Login</h1>
<div v-if="error" class="mb-4 p-3 bg-destructive/10 text-destructive rounded">
{{ error }}
</div>
<form @submit.prevent="handleLogin" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-2">Tenant ID</label>
<input
v-model="tenantId"
type="text"
required
class="w-full px-3 py-2 border rounded-md bg-background"
placeholder="123"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">Email</label>
<input
v-model="email"
type="email"
required
class="w-full px-3 py-2 border rounded-md bg-background"
placeholder="user@example.com"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">Password</label>
<input
v-model="password"
type="password"
required
class="w-full px-3 py-2 border rounded-md bg-background"
placeholder="••••••••"
/>
</div>
<button
type="submit"
:disabled="loading"
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
>
{{ loading ? 'Logging in...' : 'Login' }}
</button>
</form>
<div class="mt-4 text-center text-sm text-muted-foreground">
Don't have an account?
<NuxtLink to="/register" class="text-primary hover:underline">
Register
<div class="grid min-h-svh lg:grid-cols-2">
<div class="flex flex-col gap-4 p-6 md:p-10">
<div class="flex justify-center gap-2 md:justify-start">
<NuxtLink to="/" class="flex items-center gap-2 font-medium">
<div class="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md">
<LayoutGrid class="size-4" />
</div>
Neo Platform
</NuxtLink>
</div>
<div class="flex flex-1 items-center justify-center">
<div class="w-full max-w-xs">
<LoginForm />
</div>
</div>
</div>
<div class="bg-muted relative hidden lg:block">
<img
src="https://images.unsplash.com/photo-1486312338219-ce68d2c6f44d?w=1200&auto=format&fit=crop&q=80"
alt="Login cover"
class="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale"
>
</div>
</div>
</template>
<script setup lang="ts">
const config = useRuntimeConfig()
const router = useRouter()
const tenantId = ref('123')
const email = ref('')
const password = ref('')
const loading = ref(false)
const error = ref('')
const handleLogin = async () => {
try {
loading.value = true
error.value = ''
const response = await fetch(`${config.public.apiBaseUrl}/api/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-tenant-id': tenantId.value,
},
body: JSON.stringify({
email: email.value,
password: password.value,
}),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.message || 'Login failed')
}
const data = await response.json()
// Store credentials
localStorage.setItem('tenantId', tenantId.value)
localStorage.setItem('token', data.access_token)
localStorage.setItem('user', JSON.stringify(data.user))
// Redirect to home
router.push('/')
} catch (e: any) {
error.value = e.message || 'Login failed'
} finally {
loading.value = false
}
}
</script>

View File

@@ -1,87 +1,97 @@
<template>
<div class="min-h-screen bg-background flex items-center justify-center">
<div class="w-full max-w-md p-8 border rounded-lg bg-card">
<h1 class="text-3xl font-bold mb-6 text-center">Register</h1>
<div v-if="error" class="mb-4 p-3 bg-destructive/10 text-destructive rounded">
{{ error }}
</div>
<div v-if="success" class="mb-4 p-3 bg-green-500/10 text-green-600 rounded">
Registration successful! Redirecting to login...
</div>
<form @submit.prevent="handleRegister" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-2">Tenant ID</label>
<input
v-model="tenantId"
type="text"
required
class="w-full px-3 py-2 border rounded-md bg-background"
placeholder="123"
/>
<div class="min-h-screen bg-background flex items-center justify-center p-4">
<Card class="w-full max-w-md">
<CardHeader>
<CardTitle class="text-2xl text-center">Create Account</CardTitle>
<CardDescription class="text-center">
Sign up to get started with Neo Platform
</CardDescription>
</CardHeader>
<CardContent>
<div v-if="error" class="mb-4 p-3 bg-destructive/10 text-destructive rounded text-sm">
{{ error }}
</div>
<div>
<label class="block text-sm font-medium mb-2">Email</label>
<input
v-model="email"
type="email"
required
class="w-full px-3 py-2 border rounded-md bg-background"
placeholder="user@example.com"
/>
<div v-if="success" class="mb-4 p-3 bg-green-500/10 text-green-600 rounded text-sm">
Registration successful! Redirecting to login...
</div>
<div>
<label class="block text-sm font-medium mb-2">Password</label>
<input
v-model="password"
type="password"
required
minlength="6"
class="w-full px-3 py-2 border rounded-md bg-background"
placeholder="••••••••"
/>
</div>
<form @submit.prevent="handleRegister" class="space-y-4">
<div class="space-y-2">
<Label for="tenantId">Tenant ID</Label>
<Input
id="tenantId"
v-model="tenantId"
type="text"
required
placeholder="123"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">First Name (Optional)</label>
<input
v-model="firstName"
type="text"
class="w-full px-3 py-2 border rounded-md bg-background"
placeholder="John"
/>
</div>
<div class="space-y-2">
<Label for="email">Email</Label>
<Input
id="email"
v-model="email"
type="email"
required
placeholder="user@example.com"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">Last Name (Optional)</label>
<input
v-model="lastName"
type="text"
class="w-full px-3 py-2 border rounded-md bg-background"
placeholder="Doe"
/>
</div>
<div class="space-y-2">
<Label for="password">Password</Label>
<Input
id="password"
v-model="password"
type="password"
required
minlength="6"
placeholder="••••••••"
/>
<p class="text-xs text-muted-foreground">Must be at least 6 characters</p>
</div>
<button
type="submit"
:disabled="loading"
class="w-full px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
>
{{ loading ? 'Registering...' : 'Register' }}
</button>
</form>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="firstName">First Name</Label>
<Input
id="firstName"
v-model="firstName"
type="text"
placeholder="John"
/>
</div>
<div class="mt-4 text-center text-sm text-muted-foreground">
Already have an account?
<NuxtLink to="/login" class="text-primary hover:underline">
Login
</NuxtLink>
</div>
</div>
<div class="space-y-2">
<Label for="lastName">Last Name</Label>
<Input
id="lastName"
v-model="lastName"
type="text"
placeholder="Doe"
/>
</div>
</div>
<Button
type="submit"
:disabled="loading"
class="w-full"
>
{{ loading ? 'Creating account...' : 'Create Account' }}
</Button>
</form>
</CardContent>
<CardFooter class="flex justify-center">
<p class="text-sm text-muted-foreground">
Already have an account?
<NuxtLink to="/login" class="text-primary hover:underline font-medium">
Login
</NuxtLink>
</p>
</CardFooter>
</Card>
</div>
</template>