diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..de4e0d8 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +NUXT_PUBLIC_API_BASE=http://localhost:8000/v1 +NUXT_PUBLIC_GOOGLE_CLIENT_ID= \ No newline at end of file diff --git a/app/app.vue b/app/app.vue index 87beb38..ab79902 100644 --- a/app/app.vue +++ b/app/app.vue @@ -1,11 +1,13 @@ diff --git a/app/components/LoginForm.vue b/app/components/LoginForm.vue new file mode 100644 index 0000000..8d83dc9 --- /dev/null +++ b/app/components/LoginForm.vue @@ -0,0 +1,170 @@ + + + diff --git a/app/composables/useGoogleAuth.ts b/app/composables/useGoogleAuth.ts new file mode 100644 index 0000000..0e0c357 --- /dev/null +++ b/app/composables/useGoogleAuth.ts @@ -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) => void + } + } + } + } +} + +export function useGoogleAuth() { + const config = useRuntimeConfig() + const googleClientId = config.public.googleClientId as string + const loading = ref(false) + const error = ref(null) + + function loadGoogleScript(): Promise { + 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, + } +} diff --git a/app/pages/auth/login/index.vue b/app/pages/auth/login/index.vue new file mode 100644 index 0000000..1688e0d --- /dev/null +++ b/app/pages/auth/login/index.vue @@ -0,0 +1,14 @@ + + + + + diff --git a/docs/history/2026-07-23-login-form-functionality.md b/docs/history/2026-07-23-login-form-functionality.md new file mode 100644 index 0000000..c02f974 --- /dev/null +++ b/docs/history/2026-07-23-login-form-functionality.md @@ -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 `` (hanya di client, bukan SSR). + +### 5. Update `app/app.vue` +- Import dan render `` di dalam ``. + +### 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: `` 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 `` | +| `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= +``` diff --git a/nuxt.config.ts b/nuxt.config.ts index b0f678c..42bed5d 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -4,17 +4,19 @@ export default defineNuxtConfig({ devtools: { enabled: true }, modules: ['@nuxtjs/tailwindcss', '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' + }, + 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', + } } }) diff --git a/package.json b/package.json index 1ef9e65..881c45f 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,10 @@ "nuxt": "^4.5.0", "reka-ui": "^2.10.1", "shadcn-nuxt": "2.8.0", - "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "vue": "^3.5.40", - "vue-router": "^5.2.0" + "vue-router": "^5.2.0", + "vue-sonner": "^2.0.9" }, "devDependencies": { "@nuxtjs/tailwindcss": "7.0.0-beta.1",