From ea28a8786048960e4f5ba9ac5ef4b24b2c062364 Mon Sep 17 00:00:00 2001 From: Lucien Cartier-Tilet Date: Sun, 7 Dec 2025 21:27:23 +0100 Subject: [PATCH] feat: authentication with OAuth --- .env.example | 2 +- app/components/AppUser.vue | 6 +- app/components/auth/OAuthProvider.vue | 17 + app/components/ui/logout.vue | 17 +- app/composables/__tests__/useAuth.test.ts | 444 +++ .../__tests__/usePageTitle.test.ts | 158 + .../__tests__/usePocketbase.test.ts | 85 + app/composables/useAuth.ts | 84 + app/composables/usePageTitle.ts | 6 +- app/composables/usePocketbase.ts | 15 + app/layouts/default.vue | 4 +- app/middleware/__tests__/auth.test.ts | 156 + app/middleware/auth.global.ts | 14 + app/pages/login.vue | 37 + app/pages/signin.vue | 11 - features/page-title.feature | 57 + .../step_definitions/page-title.steps.mjs | 98 + features/step_definitions/page-title.steps.ts | 72 + flake.lock | 6 +- flake.nix | 20 +- nix/shell.nix | 37 +- package.json | 11 +- pb_migrations/1765135365_updated_users.js | 36 + pnpm-lock.yaml | 3217 +++++++++-------- pnpm-workspace.yaml | 1 + 25 files changed, 3143 insertions(+), 1468 deletions(-) create mode 100644 app/components/auth/OAuthProvider.vue create mode 100644 app/composables/__tests__/useAuth.test.ts create mode 100644 app/composables/__tests__/usePageTitle.test.ts create mode 100644 app/composables/__tests__/usePocketbase.test.ts create mode 100644 app/composables/useAuth.ts create mode 100644 app/composables/usePocketbase.ts create mode 100644 app/middleware/__tests__/auth.test.ts create mode 100644 app/middleware/auth.global.ts create mode 100644 app/pages/login.vue delete mode 100644 app/pages/signin.vue create mode 100644 features/page-title.feature create mode 100644 features/step_definitions/page-title.steps.mjs create mode 100644 features/step_definitions/page-title.steps.ts create mode 100644 pb_migrations/1765135365_updated_users.js diff --git a/.env.example b/.env.example index 7ea745e..974158d 100644 --- a/.env.example +++ b/.env.example @@ -1 +1 @@ -NUXT_POCKETBASE_URL='http://localhost:127.0.0.1:8090/' +NUXT_POCKETBASE_URL='http://127.0.0.1:8090/' diff --git a/app/components/AppUser.vue b/app/components/AppUser.vue index d9b6e76..b563aa8 100644 --- a/app/components/AppUser.vue +++ b/app/components/AppUser.vue @@ -1,5 +1,7 @@ - + diff --git a/app/components/auth/OAuthProvider.vue b/app/components/auth/OAuthProvider.vue new file mode 100644 index 0000000..ca45ff5 --- /dev/null +++ b/app/components/auth/OAuthProvider.vue @@ -0,0 +1,17 @@ + + + diff --git a/app/components/ui/logout.vue b/app/components/ui/logout.vue index 9b2f5ae..bd5f924 100644 --- a/app/components/ui/logout.vue +++ b/app/components/ui/logout.vue @@ -1,3 +1,18 @@ + + diff --git a/app/composables/__tests__/useAuth.test.ts b/app/composables/__tests__/useAuth.test.ts new file mode 100644 index 0000000..00a04f4 --- /dev/null +++ b/app/composables/__tests__/useAuth.test.ts @@ -0,0 +1,444 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { mockNuxtImport } from '@nuxt/test-utils/runtime'; +import type { AuthProviderInfo, AuthModel, RecordModel } from 'pocketbase'; + +/** + * Comprehensive tests for useAuth composable + * Based on specs from private/specs.md section 3.1 (Authentication API) + * + * These tests verify actual behavior, not just API existence. + */ + +// Mock PocketBase +const mockAuthStore = { + isValid: false, + record: null as AuthModel | null, + clear: vi.fn(), + onChange: vi.fn(), +}; + +const mockCollection = vi.fn(); +const mockAuthWithOAuth2 = vi.fn(); +const mockListAuthMethods = vi.fn(); +const mockAuthRefresh = vi.fn(); + +vi.mock('../usePocketbase', () => ({ + usePocketbase: () => ({ + authStore: mockAuthStore, + collection: mockCollection, + }), +})); + +// Mock router using Nuxt's test utils +const mockRouterPush = vi.fn(); +const mockRouter = { + push: mockRouterPush, +}; + +mockNuxtImport('useRouter', () => { + return () => mockRouter; +}); + +describe('useAuth', () => { + beforeEach(async () => { + // Reset all mocks + vi.clearAllMocks(); + mockAuthStore.isValid = false; + mockAuthStore.record = null; + + // Setup default mock implementations + mockCollection.mockReturnValue({ + authWithOAuth2: mockAuthWithOAuth2, + listAuthMethods: mockListAuthMethods, + authRefresh: mockAuthRefresh, + }); + + // Clear module cache to get fresh imports + vi.resetModules(); + }); + + describe('Composable Export', () => { + it('should be exported as a function', async () => { + const { useAuth } = await import('../useAuth'); + expect(useAuth).toBeDefined(); + expect(typeof useAuth).toBe('function'); + }); + + it('should return an object with auth methods and state', async () => { + const { useAuth } = await import('../useAuth'); + const auth = useAuth(); + expect(auth).toBeDefined(); + expect(typeof auth).toBe('object'); + }); + }); + + describe('Initial State', () => { + it('should initialize with user as null', async () => { + const { useAuth } = await import('../useAuth'); + const auth = useAuth(); + expect(auth.user.value).toBeNull(); + }); + + it('should initialize with isAuthenticated as false', async () => { + const { useAuth } = await import('../useAuth'); + const auth = useAuth(); + expect(auth.isAuthenticated.value).toBe(false); + }); + + it('should initialize with loading as false', async () => { + const { useAuth } = await import('../useAuth'); + const auth = useAuth(); + expect(auth.loading.value).toBe(false); + }); + + it('should initialize with error as null', async () => { + const { useAuth } = await import('../useAuth'); + const auth = useAuth(); + expect(auth.error.value).toBeNull(); + }); + }); + + describe('initAuth', () => { + it('should sync user from authStore', async () => { + const mockUser: AuthModel = { + id: 'user123', + email: 'test@example.com', + } as unknown as AuthModel; + + mockAuthStore.record = mockUser; + mockAuthStore.isValid = true; + + const { useAuth } = await import('../useAuth'); + const auth = useAuth(); + + await auth.initAuth(); + + expect(auth.user.value).toEqual(mockUser); + }); + + it('should register onChange listener', async () => { + const { useAuth } = await import('../useAuth'); + const auth = useAuth(); + + await auth.initAuth(); + + expect(mockAuthStore.onChange).toHaveBeenCalled(); + }); + + it('should update user when authStore changes', async () => { + const { useAuth } = await import('../useAuth'); + const auth = useAuth(); + + await auth.initAuth(); + + // Get the onChange callback + const onChangeCallback = mockAuthStore.onChange.mock.calls[0]?.[0]; + + // Simulate auth change + const newUser = { + id: 'newUser456', + email: 'new@example.com', + }; + + onChangeCallback('token123', newUser); + + expect(auth.user.value).toEqual(newUser); + }); + }); + + describe('login', () => { + const mockProviders: AuthProviderInfo[] = [ + { + name: 'google', + displayName: 'Google', + state: 'state123', + codeVerifier: 'verifier', + codeChallenge: 'challenge', + codeChallengeMethod: 'S256', + authURL: 'https://google.com/oauth', + }, + { + name: 'microsoft', + displayName: 'Microsoft', + state: 'state456', + codeVerifier: 'verifier2', + codeChallenge: 'challenge2', + codeChallengeMethod: 'S256', + authURL: 'https://microsoft.com/oauth', + }, + ]; + + beforeEach(() => { + mockListAuthMethods.mockResolvedValue({ + oauth2: { + enabled: true, + providers: mockProviders, + }, + }); + }); + + it('should set loading to true when login starts', async () => { + mockAuthWithOAuth2.mockImplementation(() => new Promise(() => {})); // Never resolves + + const { useAuth } = await import('../useAuth'); + const auth = useAuth(); + + const loginPromise = auth.login('google'); + + expect(auth.loading.value).toBe(true); + + // Cleanup + await Promise.race([loginPromise, new Promise((resolve) => setTimeout(resolve, 10))]); + }); + + it('should clear previous errors when starting new login', async () => { + const { useAuth } = await import('../useAuth'); + const auth = useAuth(); + + // Set an error first + auth.error.value = new Error('Previous error'); + + mockAuthWithOAuth2.mockResolvedValue({}); + + await auth.login('google'); + + expect(auth.error.value).toBeNull(); + }); + + it('should call authWithOAuth2 with correct provider', async () => { + mockAuthWithOAuth2.mockResolvedValue({}); + + const { useAuth } = await import('../useAuth'); + const auth = useAuth(); + + await auth.login('google'); + + expect(mockAuthWithOAuth2).toHaveBeenCalledWith({ provider: 'google' }); + }); + + it('should set loading to false after login completes', async () => { + mockAuthWithOAuth2.mockResolvedValue({}); + + const { useAuth } = await import('../useAuth'); + const auth = useAuth(); + + await auth.login('google'); + + expect(auth.loading.value).toBe(false); + }); + + it('should throw error if provider is not configured', async () => { + const { useAuth } = await import('../useAuth'); + const auth = useAuth(); + + await auth.login('github'); // Not in mockProviders + + expect(auth.error.value).toBeDefined(); + expect(auth.error.value?.message).toContain('github'); + expect(auth.error.value?.message).toContain('not configured'); + }); + + it('should handle OAuth errors gracefully', async () => { + const mockError = new Error('OAuth failed'); + mockAuthWithOAuth2.mockRejectedValue(mockError); + + const { useAuth } = await import('../useAuth'); + const auth = useAuth(); + + await auth.login('google'); + + expect(auth.error.value).toEqual(mockError); + expect(auth.loading.value).toBe(false); + }); + + it('should support multiple OAuth providers', async () => { + mockAuthWithOAuth2.mockResolvedValue({}); + + const { useAuth } = await import('../useAuth'); + const auth = useAuth(); + + await auth.login('google'); + expect(mockAuthWithOAuth2).toHaveBeenCalledWith({ provider: 'google' }); + + await auth.login('microsoft'); + expect(mockAuthWithOAuth2).toHaveBeenCalledWith({ provider: 'microsoft' }); + }); + + it('should return empty array when OAuth is disabled', async () => { + mockListAuthMethods.mockResolvedValue({ + oauth2: { + enabled: false, + providers: [], + }, + }); + + const { useAuth } = await import('../useAuth'); + const auth = useAuth(); + + await auth.login('google'); + + expect(auth.error.value).toBeDefined(); + expect(auth.error.value?.message).toContain('not configured'); + }); + }); + + describe('handleOAuthCallback', () => { + it('should sync user from authStore', async () => { + const mockUser: AuthModel = { + id: 'user123', + email: 'test@example.com', + } as unknown as AuthModel; + + mockAuthStore.record = mockUser; + mockAuthStore.isValid = true; + + const { useAuth } = await import('../useAuth'); + const auth = useAuth(); + + await auth.handleOAuthCallback(); + + expect(auth.user.value).toEqual(mockUser); + }); + + it('should redirect to dashboard when authenticated', async () => { + mockAuthStore.record = { + id: 'user123', + email: 'test@example.com', + } as unknown as RecordModel; + mockAuthStore.isValid = true; + + const { useAuth } = await import('../useAuth'); + const auth = useAuth(); + + await auth.handleOAuthCallback(); + + expect(mockRouterPush).toHaveBeenCalledWith('/dashboard'); + }); + + it('should redirect to home when not authenticated', async () => { + mockAuthStore.record = null; + mockAuthStore.isValid = false; + + const { useAuth } = await import('../useAuth'); + const auth = useAuth(); + + await auth.handleOAuthCallback(); + + expect(mockRouterPush).toHaveBeenCalledWith('/'); + }); + }); + + describe('refreshAuth', () => { + it('should call authRefresh on users collection', async () => { + mockAuthRefresh.mockResolvedValue({ + token: 'newToken', + record: { id: 'user123', email: 'test@example.com' }, + }); + + const { useAuth } = await import('../useAuth'); + const auth = useAuth(); + + await auth.refreshAuth(); + + expect(mockCollection).toHaveBeenCalledWith('users'); + expect(mockAuthRefresh).toHaveBeenCalled(); + }); + + it('should return the refresh result', async () => { + const mockResult = { + token: 'newToken', + record: { id: 'user123', email: 'test@example.com' }, + }; + + mockAuthRefresh.mockResolvedValue(mockResult); + + const { useAuth } = await import('../useAuth'); + const auth = useAuth(); + + const result = await auth.refreshAuth(); + + expect(result).toEqual(mockResult); + }); + }); + + describe('logout', () => { + it('should clear authStore', async () => { + const { useAuth } = await import('../useAuth'); + const auth = useAuth(); + + auth.logout(); + + expect(mockAuthStore.clear).toHaveBeenCalled(); + }); + + it('should clear authStore even when user is authenticated', async () => { + mockAuthStore.record = { + id: 'user123', + email: 'test@example.com', + } as unknown as RecordModel; + mockAuthStore.isValid = true; + + const { useAuth } = await import('../useAuth'); + const auth = useAuth(); + + auth.logout(); + + expect(mockAuthStore.clear).toHaveBeenCalled(); + }); + }); + + describe('isAuthenticated computed', () => { + it('should be false when authStore is invalid', async () => { + mockAuthStore.isValid = false; + mockAuthStore.record = { id: 'user123', email: 'test@example.com' } as unknown as RecordModel; + + const { useAuth } = await import('../useAuth'); + const auth = useAuth(); + await auth.initAuth(); + + expect(auth.isAuthenticated.value).toBe(false); + }); + + it('should be false when user is null', async () => { + mockAuthStore.isValid = true; + mockAuthStore.record = null; + + const { useAuth } = await import('../useAuth'); + const auth = useAuth(); + await auth.initAuth(); + + expect(auth.isAuthenticated.value).toBe(false); + }); + + it('should be true when authStore is valid and user exists', async () => { + mockAuthStore.isValid = true; + mockAuthStore.record = { id: 'user123', email: 'test@example.com' } as unknown as RecordModel; + + const { useAuth } = await import('../useAuth'); + const auth = useAuth(); + await auth.initAuth(); + + expect(auth.isAuthenticated.value).toBe(true); + }); + }); + + describe('Exposed API', () => { + it('should expose all required methods and properties', async () => { + const { useAuth } = await import('../useAuth'); + const auth = useAuth(); + + // State + expect(auth.user).toBeDefined(); + expect(auth.isAuthenticated).toBeDefined(); + expect(auth.loading).toBeDefined(); + expect(auth.error).toBeDefined(); + + // Methods + expect(typeof auth.initAuth).toBe('function'); + expect(typeof auth.login).toBe('function'); + expect(typeof auth.logout).toBe('function'); + expect(typeof auth.handleOAuthCallback).toBe('function'); + expect(typeof auth.refreshAuth).toBe('function'); + }); + }); +}); diff --git a/app/composables/__tests__/usePageTitle.test.ts b/app/composables/__tests__/usePageTitle.test.ts new file mode 100644 index 0000000..c5565b6 --- /dev/null +++ b/app/composables/__tests__/usePageTitle.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { usePageTitle } from '../usePageTitle'; + +/** + * Unit tests for usePageTitle composable + * + * This composable manages page titles throughout the application. + */ + +describe('usePageTitle', () => { + beforeEach(() => { + // Reset modules to ensure clean state + vi.resetModules(); + }); + + describe('Initialization', () => { + it('should initialize with default title "Tímmál"', () => { + const { title } = usePageTitle(); + expect(title.value).toBe('Tímmál'); + }); + + it('should initialize with empty page name', () => { + const { pageName } = usePageTitle(); + expect(pageName.value).toBe(null); + }); + }); + + describe('Setting Page Name', () => { + it('should update page name when setPageName is called', () => { + const { pageName, setPageName } = usePageTitle(); + + setPageName('Dashboard'); + + expect(pageName.value).toBe('Dashboard'); + }); + + it('should update title to include page name', () => { + const { title, setPageName } = usePageTitle(); + + setPageName('Dashboard'); + + expect(title.value).toBe('Dashboard - Tímmál'); + }); + + it('should handle empty string gracefully', () => { + const { title, setPageName } = usePageTitle(); + + // First set a name + setPageName('Dashboard'); + expect(title.value).toBe('Dashboard - Tímmál'); + + // Then clear it + setPageName(''); + expect(title.value).toBe('Tímmál'); + }); + + it('should handle multiple page name changes', () => { + const { title, setPageName } = usePageTitle(); + + setPageName('Dashboard'); + expect(title.value).toBe('Dashboard - Tímmál'); + + setPageName('Projects'); + expect(title.value).toBe('Projects - Tímmál'); + + setPageName('Settings'); + expect(title.value).toBe('Settings - Tímmál'); + }); + }); + + describe('Title Formatting', () => { + it('should format title as "PageName - Tímmál" when page name is set', () => { + const { title, setPageName } = usePageTitle(); + + setPageName('Reports'); + + expect(title.value).toBe('Reports - Tímmál'); + }); + + it('should format title as "Tímmál" when page name is empty', () => { + const { title, setPageName } = usePageTitle(); + setPageName(null); + + expect(title.value).toBe('Tímmál'); + }); + + it('should preserve special characters in page name', () => { + const { title, setPageName } = usePageTitle(); + + setPageName('Reports & Analytics'); + + expect(title.value).toBe('Reports & Analytics - Tímmál'); + }); + + it('should preserve unicode characters', () => { + const { title, setPageName } = usePageTitle(); + + setPageName('Paramètres'); + + expect(title.value).toBe('Paramètres - Tímmál'); + }); + }); + + describe('State Sharing', () => { + it('should share state across multiple calls', () => { + const instance1 = usePageTitle(); + const instance2 = usePageTitle(); + + // Set via first instance + instance1.setPageName('Dashboard'); + + // Should be visible in second instance + expect(instance2.title.value).toBe('Dashboard - Tímmál'); + expect(instance2.pageName.value).toBe('Dashboard'); + }); + }); + + describe('Exposed API', () => { + it('should expose title as computed', () => { + const { title } = usePageTitle(); + + expect(title).toBeDefined(); + expect(title.value).toBeDefined(); + }); + + it('should expose pageName as readonly', () => { + const { pageName } = usePageTitle(); + + expect(pageName).toBeDefined(); + expect(pageName.value).toBeDefined(); + }); + + it('should expose setPageName method', () => { + const { setPageName } = usePageTitle(); + + expect(setPageName).toBeDefined(); + expect(typeof setPageName).toBe('function'); + }); + + it('title should be readonly (TypeScript enforced)', () => { + const { title } = usePageTitle(); + + // readonly() is enforced by TypeScript, not at runtime + // This test just verifies the property exists + expect(title).toBeDefined(); + expect(title.value).toBeDefined(); + }); + + it('pageName should be readonly (TypeScript enforced)', () => { + const { pageName } = usePageTitle(); + + // readonly() is enforced by TypeScript, not at runtime + // This test just verifies the property exists + expect(pageName).toBeDefined(); + expect(pageName.value).toBeDefined(); + }); + }); +}); diff --git a/app/composables/__tests__/usePocketbase.test.ts b/app/composables/__tests__/usePocketbase.test.ts new file mode 100644 index 0000000..6e56f9f --- /dev/null +++ b/app/composables/__tests__/usePocketbase.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { usePocketbase } from '../usePocketbase'; +import PocketBase from 'pocketbase'; + +/** + * Tests for usePocketbase composable + * + * This composable provides a singleton PocketBase client instance. + */ + +describe('usePocketbase', () => { + beforeEach(() => { + // Reset modules to clear singleton between test suites + vi.resetModules(); + }); + + describe('Instance Creation', () => { + it('should return a PocketBase instance', () => { + const pb = usePocketbase(); + expect(pb).toBeInstanceOf(PocketBase); + }); + + it('should return the same instance on multiple calls (singleton)', () => { + const pb1 = usePocketbase(); + const pb2 = usePocketbase(); + expect(pb1).toBe(pb2); + }); + }); + + describe('Configuration', () => { + it('should initialize with URL from runtime config or fallback to default', () => { + const pb = usePocketbase(); + + // Should have a baseURL set (either from config or default) + expect(pb.baseURL).toBeDefined(); + expect(typeof pb.baseURL).toBe('string'); + expect(pb.baseURL).toMatch(/^https?:\/\//); // Valid URL format + }); + + it('should use a valid URL format', () => { + const pb = usePocketbase(); + + // URL should be a valid HTTP/HTTPS URL + expect(pb.baseURL).toMatch(/^https?:\/\/[\w\d.:]+/); + }); + }); + + describe('Singleton Behavior', () => { + it('should maintain singleton across multiple imports', () => { + const pb1 = usePocketbase(); + const pb2 = usePocketbase(); + const pb3 = usePocketbase(); + + expect(pb1).toBe(pb2); + expect(pb2).toBe(pb3); + }); + + it('should share auth state across all consumers', () => { + const pb1 = usePocketbase(); + const pb2 = usePocketbase(); + + // Both should share the same authStore + expect(pb1.authStore).toBe(pb2.authStore); + }); + }); + + describe('PocketBase Features', () => { + it('should have authStore available', () => { + const pb = usePocketbase(); + expect(pb.authStore).toBeDefined(); + }); + + it('should have collection method available', () => { + const pb = usePocketbase(); + expect(pb.collection).toBeDefined(); + expect(typeof pb.collection).toBe('function'); + }); + + it('should be able to access collections', () => { + const pb = usePocketbase(); + const usersCollection = pb.collection('users'); + expect(usersCollection).toBeDefined(); + }); + }); +}); diff --git a/app/composables/useAuth.ts b/app/composables/useAuth.ts new file mode 100644 index 0000000..aaa68d1 --- /dev/null +++ b/app/composables/useAuth.ts @@ -0,0 +1,84 @@ +import type { AuthProviderInfo, RecordModel } from 'pocketbase'; +import { usePocketbase } from './usePocketbase'; + +export interface LoggedInUser extends RecordModel { + id: string; + email: string; + emailVisibility: boolean; + verified: boolean; + name: string; + avatar?: string; + created: Date; + updated: Date; +} + +const user = ref(null); +const loading = ref(false); +const error = ref(null); + +export const useAuth = () => { + const pb = usePocketbase(); + const router = useRouter(); + + const userCollection = 'users'; + + const isAuthenticated = computed(() => pb.authStore.isValid && !!user.value); + + const initAuth = async () => { + user.value = pb.authStore.record as LoggedInUser; + pb.authStore.onChange((_token, model) => (user.value = model as LoggedInUser)); + }; + + const authProviders = async (): Promise => { + const authMethods = await pb.collection(userCollection).listAuthMethods(); + return authMethods.oauth2.enabled ? authMethods.oauth2.providers : []; + }; + + const login = async (provider: string) => { + loading.value = true; + error.value = null; + try { + const providers = await authProviders(); + const providerData = providers.find((p) => p.name === provider); + if (!providerData) { + throw new Error(`${provider} OAuth is not configured`); + } + const response = await pb.collection(userCollection).authWithOAuth2({ provider }); + user.value = response.record as LoggedInUser; + } catch (pbError) { + error.value = pbError as Error; + } finally { + loading.value = false; + } + }; + + const refreshAuth = async () => await pb.collection(userCollection).authRefresh(); + + const handleOAuthCallback = async () => { + user.value = pb.authStore.record as LoggedInUser; + if (isAuthenticated.value) { + await router.push('/dashboard'); + } else { + await router.push('/'); + } + }; + + const logout = () => { + pb.authStore.clear(); + user.value = null; + error.value = null; + }; + + return { + user, + loading, + error, + isAuthenticated, + login, + logout, + initAuth, + refreshAuth, + handleOAuthCallback, + authProviders, + }; +}; diff --git a/app/composables/usePageTitle.ts b/app/composables/usePageTitle.ts index d90277a..f2398ea 100644 --- a/app/composables/usePageTitle.ts +++ b/app/composables/usePageTitle.ts @@ -1,8 +1,8 @@ export const usePageTitle = () => { - const pageName = useState('pageName', () => ''); - const title = computed(() => (pageName.value.length > 0 ? `${pageName.value} - Tímmál` : 'Tímmál')); + const pageName = useState('pageName', () => null); + const title = computed(() => ((pageName.value ?? '').length > 0 ? `${pageName.value} - Tímmál` : 'Tímmál')); - const setPageName = (newName: string) => { + const setPageName = (newName: string | null) => { pageName.value = newName; useHead({ title: title.value }); }; diff --git a/app/composables/usePocketbase.ts b/app/composables/usePocketbase.ts new file mode 100644 index 0000000..594e6ce --- /dev/null +++ b/app/composables/usePocketbase.ts @@ -0,0 +1,15 @@ +import PocketBase from 'pocketbase'; + +let pbInstance: PocketBase | null = null; + +export const usePocketbase = () => { + if (!pbInstance) { + const config = useRuntimeConfig(); + pbInstance = new PocketBase(config.pocketbaseUrl || 'http://localhost:8090'); + if (import.meta.server) { + pbInstance.autoCancellation(false); + } + } + + return pbInstance; +}; diff --git a/app/layouts/default.vue b/app/layouts/default.vue index ef06084..29a3a02 100644 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -1,9 +1,9 @@