feat: implement login functionality with Google authentication and form validation
This commit is contained in:
parent
4094d4d824
commit
b3eab9320c
2
.env.example
Normal file
2
.env.example
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
NUXT_PUBLIC_API_BASE=http://localhost:8000/v1
|
||||||
|
NUXT_PUBLIC_GOOGLE_CLIENT_ID=
|
||||||
@ -1,11 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<NuxtRouteAnnouncer />
|
<NuxtRouteAnnouncer />
|
||||||
<NuxtWelcome />
|
<NuxtPage />
|
||||||
|
<ClientOnly>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Toaster from '@/components/ui/sonner/Toaster.vue'
|
import Toaster from '@/components/ui/sonner/Sonner.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
170
app/components/LoginForm.vue
Normal file
170
app/components/LoginForm.vue
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import * as z from 'zod'
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: 'Masuk Akun',
|
||||||
|
ogTitle: 'Masuk Akun',
|
||||||
|
description: 'Masuk untuk mengakses akun Anda',
|
||||||
|
ogDescription: 'Masuk untuk mengakses akun Anda',
|
||||||
|
})
|
||||||
|
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const router = useRouter()
|
||||||
|
const loading = ref(false)
|
||||||
|
const { signInWithGoogle, loading: googleLoading } = useGoogleAuth()
|
||||||
|
|
||||||
|
const login = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const errors = ref<Record<string, string>>({})
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
login: z.string().min(1, 'Username atau email wajib diisi'),
|
||||||
|
password: z.string().min(8, 'Kata sandi minimal 8 karakter'),
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
errors.value = {}
|
||||||
|
|
||||||
|
const result = schema.safeParse({ login: login.value, password: password.value })
|
||||||
|
if (!result.success) {
|
||||||
|
for (const issue of result.error.issues) {
|
||||||
|
const field = issue.path[0] as string
|
||||||
|
errors.value[field] = issue.message
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await $fetch(`${config.public.apiBase}/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
login: login.value,
|
||||||
|
password: password.value,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
toast.success('Selamat datang kembali!', {
|
||||||
|
description: `Masuk sebagai ${login.value}`,
|
||||||
|
})
|
||||||
|
router.push('/')
|
||||||
|
} catch (error: any) {
|
||||||
|
const detail = error?.data?.detail
|
||||||
|
if (detail && Array.isArray(detail)) {
|
||||||
|
const fieldErrors = detail
|
||||||
|
.filter((e: any) => e.loc?.length >= 2)
|
||||||
|
.map((e: any) => ({
|
||||||
|
path: e.loc[e.loc.length - 1],
|
||||||
|
message: e.msg
|
||||||
|
}))
|
||||||
|
for (const fe of fieldErrors) {
|
||||||
|
errors.value[fe.path] = fe.message
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error('Gagal masuk', {
|
||||||
|
description: error?.data?.message || 'Ups, terjadi kesalahan. Silakan coba lagi beberapa saat.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGoogleLogin() {
|
||||||
|
await signInWithGoogle(
|
||||||
|
(result) => {
|
||||||
|
toast.success('Selamat datang kembali!', {
|
||||||
|
description: `Masuk sebagai ${result.user.email}`,
|
||||||
|
})
|
||||||
|
router.push('/')
|
||||||
|
},
|
||||||
|
(errorMsg) => {
|
||||||
|
toast.error('Gagal masuk', {
|
||||||
|
description: errorMsg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="text-center">
|
||||||
|
<CardTitle class="text-xl">
|
||||||
|
Selamat datang kembali!
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Masukkan kredensial Anda untuk mengakses akun.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form @submit.prevent="onSubmit">
|
||||||
|
<FieldGroup>
|
||||||
|
<Field :class="{ 'text-destructive': errors.login }">
|
||||||
|
<FieldLabel for="email">
|
||||||
|
Username atau Email <span class="text-destructive">*</span>
|
||||||
|
</FieldLabel>
|
||||||
|
<Input id="email" v-model="login" type="text" placeholder="Masukkan username atau email"
|
||||||
|
:class="{ 'border-destructive': errors.login }" required />
|
||||||
|
<p v-if="errors.login" class="text-sm text-destructive mt-1">
|
||||||
|
{{ errors.login }}
|
||||||
|
</p>
|
||||||
|
</Field>
|
||||||
|
<Field :class="{ 'text-destructive': errors.password }">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<FieldLabel for="password">
|
||||||
|
Kata Sandi <span class="text-destructive">*</span>
|
||||||
|
</FieldLabel>
|
||||||
|
<NuxtLink to="/auth/forgot-password" class="ml-auto text-sm underline-offset-4 hover:underline">
|
||||||
|
Lupa kata sandi?
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<Input id="password" v-model="password" type="password" placeholder="Masukkan kata sandi"
|
||||||
|
:class="{ 'border-destructive': errors.password }" required />
|
||||||
|
<p v-if="errors.password" class="text-sm text-destructive mt-1">
|
||||||
|
{{ errors.password }}
|
||||||
|
</p>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Button type="submit" :disabled="loading">
|
||||||
|
<span v-if="loading" class="i-lucide-loader-2 h-4 w-4 animate-spin mr-2" />
|
||||||
|
Masuk
|
||||||
|
</Button>
|
||||||
|
<FieldDescription class="text-center">
|
||||||
|
Tidak punya akun?
|
||||||
|
<NuxtLink to="/auth/register" class="text-primary hover:underline">
|
||||||
|
Daftar
|
||||||
|
</NuxtLink>
|
||||||
|
</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
<FieldSeparator class="*:data-[slot=field-separator-content]:bg-card">
|
||||||
|
Atau lanjutkan dengan
|
||||||
|
</FieldSeparator>
|
||||||
|
<Field>
|
||||||
|
<Button variant="outline" type="button" :disabled="googleLoading" @click="handleGoogleLogin">
|
||||||
|
<span v-if="googleLoading" class="i-lucide-loader-2 h-4 w-4 animate-spin mr-2" />
|
||||||
|
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-4 w-4 mr-2">
|
||||||
|
<path
|
||||||
|
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
|
||||||
|
fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
Masuk dengan Google
|
||||||
|
</Button>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<FieldDescription class="px-6 text-center">
|
||||||
|
Dengan masuk, Anda menyetujui
|
||||||
|
<NuxtLink to="/terms" class="text-primary hover:underline">
|
||||||
|
Ketentuan Layanan
|
||||||
|
</NuxtLink>
|
||||||
|
dan
|
||||||
|
<NuxtLink to="/privacy" class="text-primary hover:underline">
|
||||||
|
Kebijakan Privasi
|
||||||
|
</NuxtLink>.
|
||||||
|
</FieldDescription>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
104
app/composables/useGoogleAuth.ts
Normal file
104
app/composables/useGoogleAuth.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
interface GoogleCredentialResponse {
|
||||||
|
credential: string
|
||||||
|
select_by: string
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
google?: {
|
||||||
|
accounts: {
|
||||||
|
id: {
|
||||||
|
initialize: (config: {
|
||||||
|
client_id: string
|
||||||
|
callback: (response: GoogleCredentialResponse) => void
|
||||||
|
auto_select?: boolean
|
||||||
|
}) => void
|
||||||
|
prompt: (callback?: (notification: { isNotDisplayed: () => boolean; isSkippedMoment: () => boolean }) => void) => void
|
||||||
|
renderButton: (parent: HTMLElement, config: Record<string, unknown>) => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGoogleAuth() {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const googleClientId = config.public.googleClientId as string
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
function loadGoogleScript(): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (window.google) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingScript = document.querySelector('script[src="https://accounts.google.com/gsi/client"]')
|
||||||
|
if (existingScript) {
|
||||||
|
existingScript.addEventListener('load', () => resolve(), { once: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement('script')
|
||||||
|
script.src = 'https://accounts.google.com/gsi/client'
|
||||||
|
script.async = true
|
||||||
|
script.defer = true
|
||||||
|
script.onload = () => resolve()
|
||||||
|
script.onerror = () => reject(new Error('Gagal memuat Google Identity Services'))
|
||||||
|
document.head.appendChild(script)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signInWithGoogle(onResult?: (result: any) => void, onError?: (msg: string) => void) {
|
||||||
|
error.value = null
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadGoogleScript()
|
||||||
|
|
||||||
|
if (!googleClientId) {
|
||||||
|
throw new Error('Google Client ID tidak dikonfigurasi')
|
||||||
|
}
|
||||||
|
|
||||||
|
window.google!.accounts.id.initialize({
|
||||||
|
client_id: googleClientId,
|
||||||
|
callback: async (response: GoogleCredentialResponse) => {
|
||||||
|
try {
|
||||||
|
const apiBase = config.public.apiBase
|
||||||
|
const result = await $fetch(`${apiBase}/auth/google-login`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { credential: response.credential },
|
||||||
|
})
|
||||||
|
onResult?.(result)
|
||||||
|
} catch (e: any) {
|
||||||
|
const detail = e?.data?.detail
|
||||||
|
const msg = detail?.message || 'Gagal masuk dengan Google'
|
||||||
|
error.value = msg
|
||||||
|
onError?.(msg)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auto_select: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
window.google!.accounts.id.prompt((notification) => {
|
||||||
|
if (notification.isNotDisplayed()) {
|
||||||
|
loading.value = false
|
||||||
|
onError?.('Popup Google tidak dapat ditampilkan. Silakan coba metode lain.')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
loading.value = false
|
||||||
|
onError?.(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
signInWithGoogle,
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/pages/auth/login/index.vue
Normal file
14
app/pages/auth/login/index.vue
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import LoginForm from '@/components/LoginForm.vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-6">
|
||||||
|
<LoginForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
77
docs/history/2026-07-23-login-form-functionality.md
Normal file
77
docs/history/2026-07-23-login-form-functionality.md
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
# Login Form Functionality - Session 1
|
||||||
|
|
||||||
|
**Tanggal:** 2026-07-23
|
||||||
|
**Status:** Selesai
|
||||||
|
|
||||||
|
## Tujuan
|
||||||
|
|
||||||
|
Menambahkan fungsionalitas login pada `web2/app/components/LoginForm.vue` dengan referensi dari `web/app/pages/auth/login.vue`.
|
||||||
|
|
||||||
|
## Yang Dikerjakan
|
||||||
|
|
||||||
|
### 1. Install `vue-sonner`
|
||||||
|
- Awalnya install `sonner`, tapi paket tersebut menarik React sebagai dependency dan menyebabkan error `Invalid hook call` / `Cannot read properties of null (reading 'useState')`.
|
||||||
|
- Solusi: ganti dengan `vue-sonner` (Vue-only fork).
|
||||||
|
|
||||||
|
### 2. Update `nuxt.config.ts`
|
||||||
|
- Tambah `runtimeConfig` untuk `apiBase` dan `googleClientId`.
|
||||||
|
- Tambah `app.head` untuk title template.
|
||||||
|
|
||||||
|
### 3. Buat `app/composables/useGoogleAuth.ts`
|
||||||
|
- Composable untuk Google Identity Services (GIS) One Tap.
|
||||||
|
- Load script `https://accounts.google.com/gsi/client` secara dinamis.
|
||||||
|
- Panggil `POST /auth/google-login` ke backend setelah credential diterima.
|
||||||
|
- Return `loading`, `error`, dan `signInWithGoogle()`.
|
||||||
|
|
||||||
|
### 4. Buat `app/components/ui/sonner/Sonner.vue`
|
||||||
|
- Wrapper component untuk `vue-sonner`'s `Toaster`.
|
||||||
|
- Di-render di `app.vue` dengan `<ClientOnly>` (hanya di client, bukan SSR).
|
||||||
|
|
||||||
|
### 5. Update `app/app.vue`
|
||||||
|
- Import dan render `<Toaster />` di dalam `<ClientOnly>`.
|
||||||
|
|
||||||
|
### 6. Update `app/components/LoginForm.vue`
|
||||||
|
- Validasi form dengan Zod (`login` wajib, `password` min 8 karakter).
|
||||||
|
- Submit handler: `POST /auth/login` via `$fetch`.
|
||||||
|
- Google login handler: `useGoogleAuth().signInWithGoogle()`.
|
||||||
|
- Loading state pada tombol Masuk dan tombol Google.
|
||||||
|
- Error handling: field-level errors + toast notification (success/error).
|
||||||
|
- Navigasi: `<NuxtLink>` untuk "Daftar", "Lupa kata sandi?", "Ketentuan Layanan", "Kebijakan Privasi".
|
||||||
|
|
||||||
|
## File yang Diubah/Dibuat
|
||||||
|
|
||||||
|
| File | Aksi |
|
||||||
|
|------|------|
|
||||||
|
| `nuxt.config.ts` | Diubah - tambah `runtimeConfig` + `app.head` |
|
||||||
|
| `app/composables/useGoogleAuth.ts` | Dibuat |
|
||||||
|
| `app/components/ui/sonner/Sonner.vue` | Dibuat |
|
||||||
|
| `app/components/ui/sonner/index.ts` | Dibuat (auto) |
|
||||||
|
| `app/app.vue` | Diubah - tambah `<Toaster />` |
|
||||||
|
| `app/components/LoginForm.vue` | Diubah - tambah fungsionalitas |
|
||||||
|
| `package.json` | Diubah - tambah `vue-sonner` |
|
||||||
|
|
||||||
|
## Issues yang Dihadapi
|
||||||
|
|
||||||
|
### 1. `Cannot read properties of null (reading 'useState')`
|
||||||
|
- **Penyebab:** `sonner` (multi-framework) menarik React ke dalam project Vue.
|
||||||
|
- **Solusi:** Ganti dengan `vue-sonner`.
|
||||||
|
|
||||||
|
### 2. `Invalid hook call` (React hooks error)
|
||||||
|
- **Penyebab:** Sisa dependency React dari paket `sonner` masih ada di `node_modules/`.
|
||||||
|
- **Solusi:** Hapus manual `node_modules/react`, `node_modules/react-dom`, `node_modules/@types/react`.
|
||||||
|
|
||||||
|
### 3. Build error `Could not load Toaster.vue`
|
||||||
|
- **Penyebab:** File di direktori `sonner/` bernama `Sonner.vue` tapi import di `app.vue` mencari `Toaster.vue`.
|
||||||
|
- **Solusi:** Update import path di `app.vue` ke `Sonner.vue`.
|
||||||
|
|
||||||
|
## API yang Digunakan
|
||||||
|
|
||||||
|
- `POST /v1/auth/login` - Body: `{ login, password }`
|
||||||
|
- `POST /v1/auth/google-login` - Body: `{ credential }` (Google JWT)
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
```
|
||||||
|
NUXT_PUBLIC_API_BASE=http://localhost:8000/v1
|
||||||
|
NUXT_PUBLIC_GOOGLE_CLIENT_ID=
|
||||||
|
```
|
||||||
@ -4,17 +4,19 @@ export default defineNuxtConfig({
|
|||||||
devtools: { enabled: true },
|
devtools: { enabled: true },
|
||||||
modules: ['@nuxtjs/tailwindcss', 'shadcn-nuxt'],
|
modules: ['@nuxtjs/tailwindcss', 'shadcn-nuxt'],
|
||||||
shadcn: {
|
shadcn: {
|
||||||
/**
|
|
||||||
* Prefix for all the imported component.
|
|
||||||
* @default "Ui"
|
|
||||||
*/
|
|
||||||
prefix: '',
|
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'
|
componentDir: '@/components/ui'
|
||||||
|
},
|
||||||
|
runtimeConfig: {
|
||||||
|
public: {
|
||||||
|
apiBase: process.env.NUXT_PUBLIC_API_BASE || 'http://localhost:8000/v1',
|
||||||
|
googleClientId: process.env.NUXT_PUBLIC_GOOGLE_CLIENT_ID || '',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
head: {
|
||||||
|
title: 'Manajemen Bisnis',
|
||||||
|
titleTemplate: '%s - Profitra',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -17,10 +17,10 @@
|
|||||||
"nuxt": "^4.5.0",
|
"nuxt": "^4.5.0",
|
||||||
"reka-ui": "^2.10.1",
|
"reka-ui": "^2.10.1",
|
||||||
"shadcn-nuxt": "2.8.0",
|
"shadcn-nuxt": "2.8.0",
|
||||||
"sonner": "^2.0.7",
|
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"vue": "^3.5.40",
|
"vue": "^3.5.40",
|
||||||
"vue-router": "^5.2.0"
|
"vue-router": "^5.2.0",
|
||||||
|
"vue-sonner": "^2.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nuxtjs/tailwindcss": "7.0.0-beta.1",
|
"@nuxtjs/tailwindcss": "7.0.0-beta.1",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user