feat: authentication with OAuth
All checks were successful
ci / ci (push) Successful in 19m53s

This commit is contained in:
2025-12-07 21:27:23 +01:00
parent 4e9b4a19b8
commit ea28a87860
25 changed files with 3143 additions and 1468 deletions

View File

@@ -1 +1 @@
NUXT_POCKETBASE_URL='http://localhost:127.0.0.1:8090/' NUXT_POCKETBASE_URL='http://127.0.0.1:8090/'

View File

@@ -1,5 +1,7 @@
<template> <template>
<UUser name="name" description="email" /> <UUser v-if="user" :name="user.name" :description="user.email" :avatar="{ src: user.avatar }" />
</template> </template>
<script lang="ts" setup></script> <script lang="ts" setup>
const { user } = useAuth();
</script>

View File

@@ -0,0 +1,17 @@
<template>
<UButton
color="neutral"
size="xl"
class="flex cursor-pointer items-center justify-center gap-3 overflow-hidden"
@click="login(provider.name)"
>
Continue with {{ provider.displayName }}
</UButton>
</template>
<script lang="ts" setup>
import type { AuthProviderInfo } from 'pocketbase';
const { login } = useAuth();
const { provider } = defineProps<{ provider: AuthProviderInfo }>();
</script>

View File

@@ -1,3 +1,18 @@
<template> <template>
<UButton color="neutral" variant="ghost" icon="i-lucide-log-out" size="xl"> Log Out </UButton> <UButton color="neutral" variant="ghost" icon="i-lucide-log-out" size="xl" @click="onLogout"> Log Out </UButton>
</template> </template>
<script setup lang="ts">
const { logout } = useAuth();
const toast = useToast();
const onLogout = () => {
logout();
navigateTo('/');
toast.add({
title: 'Successfully logged out!',
description: 'You successfully logged out of your account and have been taken back to the websites welcome page.',
color: 'success',
});
};
</script>

View File

@@ -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');
});
});
});

View File

@@ -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();
});
});
});

View File

@@ -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();
});
});
});

View File

@@ -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<LoggedInUser | null>(null);
const loading = ref<boolean>(false);
const error = ref<Error | null>(null);
export const useAuth = () => {
const pb = usePocketbase();
const router = useRouter();
const userCollection = 'users';
const isAuthenticated = computed<boolean>(() => 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<AuthProviderInfo[]> => {
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,
};
};

View File

@@ -1,8 +1,8 @@
export const usePageTitle = () => { export const usePageTitle = () => {
const pageName = useState<string>('pageName', () => ''); const pageName = useState<string | null>('pageName', () => null);
const title = computed<string>(() => (pageName.value.length > 0 ? `${pageName.value} - Tímmál` : 'Tímmál')); const title = computed<string>(() => ((pageName.value ?? '').length > 0 ? `${pageName.value} - Tímmál` : 'Tímmál'));
const setPageName = (newName: string) => { const setPageName = (newName: string | null) => {
pageName.value = newName; pageName.value = newName;
useHead({ title: title.value }); useHead({ title: title.value });
}; };

View File

@@ -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;
};

View File

@@ -1,9 +1,9 @@
<template> <template>
<UDashboardGroup> <UDashboardGroup>
<UiSidebar /> <UiSidebar class="min-w-60" />
<UDashboardPanel> <UDashboardPanel>
<template #header> <template #header>
<UDashboardNavbar :title="pageName"> <UDashboardNavbar :title="pageName ?? ''">
<template #right> <template #right>
<UColorModeButton /> <UColorModeButton />
</template> </template>

View File

@@ -0,0 +1,156 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mockNuxtImport } from '@nuxt/test-utils/runtime';
import type { RouteLocationNormalized } from 'vue-router';
/**
* Tests for auth middleware
* Based on specs from private/specs.md:
*
* Scenario: Access protected page without auth
* Given I am not logged in
* When I try to access "/dashboard"
* Then I should be redirected to "/login"
*/
// Create mocks at module level to avoid hoisting issues
const mockState = {
isAuthenticated: false,
navigateToSpy: vi.fn(),
};
// Mock useAuth
mockNuxtImport('useAuth', () => {
return () => ({
isAuthenticated: {
get value() {
return mockState.isAuthenticated;
},
},
});
});
// Mock navigateTo
mockNuxtImport('navigateTo', () => {
return (path: string) => mockState.navigateToSpy(path);
});
describe('auth middleware', () => {
beforeEach(async () => {
// Reset state
mockState.isAuthenticated = false;
mockState.navigateToSpy.mockClear();
});
it('should redirect to /login when user is not authenticated', async () => {
mockState.isAuthenticated = false;
const { default: authMiddleware } = await import('../auth.global');
const to = {
path: '/dashboard',
fullPath: '/dashboard',
} as RouteLocationNormalized;
const from = {
path: '/',
fullPath: '/',
} as RouteLocationNormalized;
await authMiddleware(to, from);
expect(mockState.navigateToSpy).toHaveBeenCalledWith({
path: '/login',
query: {
redirect: '/dashboard',
},
});
});
it('should allow access when user is authenticated', async () => {
mockState.isAuthenticated = true;
const { default: authMiddleware } = await import('../auth.global');
const to = {
path: '/dashboard',
fullPath: '/dashboard',
} as RouteLocationNormalized;
const from = {
path: '/login',
fullPath: '/login',
} as RouteLocationNormalized;
const result = await authMiddleware(to, from);
expect(mockState.navigateToSpy).not.toHaveBeenCalled();
expect(result).toBeUndefined(); // No redirect = allow access
});
it('should not redirect if already on login page', async () => {
mockState.isAuthenticated = false;
const { default: authMiddleware } = await import('../auth.global');
const to = {
path: '/login',
fullPath: '/login',
} as RouteLocationNormalized;
const from = {
path: '/dashboard',
fullPath: '/dashboard',
} as RouteLocationNormalized;
const result = await authMiddleware(to, from);
expect(mockState.navigateToSpy).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
it('should allow access to home page without authentication', async () => {
mockState.isAuthenticated = false;
const { default: authMiddleware } = await import('../auth.global');
const to = {
path: '/',
fullPath: '/',
} as RouteLocationNormalized;
const from = {
path: '/somewhere',
fullPath: '/somewhere',
} as RouteLocationNormalized;
const result = await authMiddleware(to, from);
expect(mockState.navigateToSpy).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
it('should redirect to /login for any protected route', async () => {
mockState.isAuthenticated = false;
const { default: authMiddleware } = await import('../auth.global');
const to = {
path: '/projects',
fullPath: '/projects',
} as RouteLocationNormalized;
const from = {
path: '/',
fullPath: '/',
} as RouteLocationNormalized;
await authMiddleware(to, from);
expect(mockState.navigateToSpy).toHaveBeenCalledWith({
path: '/login',
query: {
redirect: '/projects',
},
});
});
});

View File

@@ -0,0 +1,14 @@
export default defineNuxtRouteMiddleware((to, _from) => {
const { isAuthenticated } = useAuth();
const allowedUnauthenticatedPaths: string[] = ['/', '/login'];
if (allowedUnauthenticatedPaths.find((p) => p === to.path)) {
return;
}
if (!isAuthenticated.value) {
return navigateTo({
path: '/login',
query: { redirect: to.fullPath },
});
}
return;
});

37
app/pages/login.vue Normal file
View File

@@ -0,0 +1,37 @@
<template>
<div>
<UPageHero title="Tímmál" />
<UPageSection id="login" title="Log in to your account" description="Welcome back to your workspace">
<div class="full-w flex justify-center">
<div class="flex flex-1 gap-3 max-w-200 flex-col items-stretch px-4 py-3 justify-center">
<UAlert
v-if="error"
title="Something went wrong!"
description="We couldn't log you in due to an error. Try again later. If the issue persists, try contacting the website's administrator."
color="error"
icon="i-lucide-circle-alert"
/>
<AuthOAuthProvider v-for="provider of providers" :key="provider.name" :provider="provider" />
</div>
</div>
</UPageSection>
</div>
</template>
<script lang="ts" setup>
definePageMeta({
layout: 'unauthenticated',
});
const route = useRoute();
const redirectPath = (route.query.redirect as string) || '/dashboard';
const { authProviders, error, isAuthenticated } = useAuth();
const providers = await authProviders();
watch(isAuthenticated, (authenticated) => {
if (authenticated) {
navigateTo(redirectPath);
}
});
</script>

View File

@@ -1,11 +0,0 @@
<template>
<UPage>
<span> Signin </span>
</UPage>
</template>
<script lang="ts" setup>
definePageMeta({
layout: 'unauthenticated',
});
</script>

View File

@@ -0,0 +1,57 @@
Feature: Page Title Management
As a user navigating the Tímmál application
I want page titles to update based on the current page
So that I can identify which page I'm on from the browser tab
Background:
Given the page title system is initialized
Scenario: Default page title on application load
When I first load the application
Then the page title should be "Tímmál"
Scenario: Setting a page name updates the title
When I navigate to the "Dashboard" page
Then the page title should be "Dashboard - Tímmál"
Scenario: Changing between different pages
When I navigate to the "Dashboard" page
Then the page title should be "Dashboard - Tímmál"
When I navigate to the "Projects" page
Then the page title should be "Projects - Tímmál"
When I navigate to the "Reports" page
Then the page title should be "Reports - Tímmál"
Scenario: Clearing page name returns to default
Given I am on the "Settings" page
And the page title is "Settings - Tímmál"
When I clear the page name
Then the page title should be "Tímmál"
Scenario: Page title with special characters
When I navigate to the "Reports & Analytics" page
Then the page title should be "Reports & Analytics - Tímmál"
Scenario: Page title with unicode characters
When I navigate to the "Paramètres" page
Then the page title should be "Paramètres - Tímmál"
Scenario: Multiple users share the same page title state
Given user "Alice" sets the page name to "Dashboard"
When user "Bob" checks the page title
Then user "Bob" should see "Dashboard - Tímmál"
Scenario Outline: Various page names
When I navigate to the "<page_name>" page
Then the page title should be "<expected_title>"
Examples:
| page_name | expected_title |
| Dashboard | Dashboard - Tímmál |
| Projects | Projects - Tímmál |
| Tasks | Tasks - Tímmál |
| Reports | Reports - Tímmál |
| Settings | Settings - Tímmál |
| Profile | Profile - Tímmál |
| Time Tracking | Time Tracking - Tímmál |
| User Management | User Management - Tímmál |

View File

@@ -0,0 +1,98 @@
import { Given, When, Then, Before } from '@cucumber/cucumber';
import { expect } from 'chai';
// Shared state (simulating useState behavior)
const sharedState = { pageName: '' };
// Simple mock of usePageTitle behavior for Cucumber tests
// This simulates the useState() sharing behavior
const createPageTitleMock = () => {
return {
get title() {
return {
get value() {
return sharedState.pageName.length > 0 ? `${sharedState.pageName} - Tímmál` : 'Tímmál';
},
};
},
get pageName() {
return {
get value() {
return sharedState.pageName;
},
};
},
setPageName(newName) {
sharedState.pageName = newName;
},
};
};
// Instance for step definitions
let pageTitleInstance;
const userInstances = new Map();
Before(function () {
// Reset shared state before each scenario
sharedState.pageName = '';
userInstances.clear();
pageTitleInstance = null;
});
Given('the page title system is initialized', function () {
pageTitleInstance = createPageTitleMock();
pageTitleInstance.setPageName('');
});
When('I first load the application', function () {
pageTitleInstance = createPageTitleMock();
});
When('I navigate to the {string} page', function (pageName) {
if (!pageTitleInstance) {
pageTitleInstance = createPageTitleMock();
}
pageTitleInstance.setPageName(pageName);
});
When('I clear the page name', function () {
pageTitleInstance.setPageName('');
});
Given('I am on the {string} page', function (pageName) {
if (!pageTitleInstance) {
pageTitleInstance = createPageTitleMock();
}
pageTitleInstance.setPageName(pageName);
});
Given('the page title is {string}', function (expectedTitle) {
expect(pageTitleInstance.title.value).to.equal(expectedTitle);
});
Given('user {string} sets the page name to {string}', function (userName, pageName) {
let userInstance = userInstances.get(userName);
if (!userInstance) {
userInstance = createPageTitleMock();
userInstances.set(userName, userInstance);
}
userInstance.setPageName(pageName);
});
When('user {string} checks the page title', function (userName) {
let userInstance = userInstances.get(userName);
if (!userInstance) {
userInstance = createPageTitleMock();
userInstances.set(userName, userInstance);
}
});
Then('the page title should be {string}', function (expectedTitle) {
expect(pageTitleInstance.title.value).to.equal(expectedTitle);
});
Then('user {string} should see {string}', function (userName, expectedTitle) {
const userInstance = userInstances.get(userName);
expect(userInstance).to.not.be.undefined;
expect(userInstance.title.value).to.equal(expectedTitle);
});

View File

@@ -0,0 +1,72 @@
import { Given, When, Then, Before } from '@cucumber/cucumber';
import { expect } from 'vitest';
import { usePageTitle } from '../../app/composables/usePageTitle';
// Store the composable instance
let pageTitleInstance: ReturnType<typeof usePageTitle>;
let userInstances: Map<string, ReturnType<typeof usePageTitle>>;
Before(function () {
// Reset before each scenario
userInstances = new Map();
});
Given('the page title system is initialized', function () {
pageTitleInstance = usePageTitle();
// Reset to default state
pageTitleInstance.setPageName('');
});
When('I first load the application', function () {
pageTitleInstance = usePageTitle();
});
When('I navigate to the {string} page', function (pageName: string) {
if (!pageTitleInstance) {
pageTitleInstance = usePageTitle();
}
pageTitleInstance.setPageName(pageName);
});
When('I clear the page name', function () {
pageTitleInstance.setPageName('');
});
Given('I am on the {string} page', function (pageName: string) {
if (!pageTitleInstance) {
pageTitleInstance = usePageTitle();
}
pageTitleInstance.setPageName(pageName);
});
Given('the page title is {string}', function (expectedTitle: string) {
expect(pageTitleInstance.title.value).toBe(expectedTitle);
});
Given('user {string} sets the page name to {string}', function (userName: string, pageName: string) {
let userInstance = userInstances.get(userName);
if (!userInstance) {
userInstance = usePageTitle();
userInstances.set(userName, userInstance);
}
userInstance.setPageName(pageName);
});
When('user {string} checks the page title', function (userName: string) {
let userInstance = userInstances.get(userName);
if (!userInstance) {
userInstance = usePageTitle();
userInstances.set(userName, userInstance);
}
// Just store the instance, we'll check in Then step
});
Then('the page title should be {string}', function (expectedTitle: string) {
expect(pageTitleInstance.title.value).toBe(expectedTitle);
});
Then('user {string} should see {string}', function (userName: string, expectedTitle: string) {
const userInstance = userInstances.get(userName);
expect(userInstance).toBeDefined();
expect(userInstance!.title.value).toBe(expectedTitle);
});

6
flake.lock generated
View File

@@ -68,11 +68,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1764927628, "lastModified": 1765320738,
"narHash": "sha256-AH2H5O9i7k3oarg3MooAnQtZxo44qxrUTUuvGOy/OEc=", "narHash": "sha256-tYYtF9NRZRzVHJpism+Tv4E5/dVJZ92ECCKz1hF1BMA=",
"owner": "cachix", "owner": "cachix",
"repo": "devenv", "repo": "devenv",
"rev": "247d7027f91368054fb0eefbd755a73d42b66fee", "rev": "43682032927f3f5cfa5af539634985aba5c3fee3",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@@ -1,13 +1,17 @@
{ {
inputs, inputs,
pkgs, pkgs,
self,
... ...
}: }:
inputs.devenv.lib.mkShell { inputs.devenv.lib.mkShell
{
inherit inputs pkgs; inherit inputs pkgs;
modules = [ modules = [
({pkgs, config, ...}: { ({
pkgs,
config,
...
}: {
packages = with pkgs; [ packages = with pkgs; [
rustywind rustywind
nodePackages.prettier nodePackages.prettier
@@ -16,6 +20,7 @@ inputs.devenv.lib.mkShell {
# Node # Node
nodejs_24 nodejs_24
nodePackages.pnpm nodePackages.pnpm
playwright-driver.browsers
pocketbase pocketbase
]; ];
@@ -32,6 +37,31 @@ inputs.devenv.lib.mkShell {
dev.exec = "${nodePackages.pnpm}/bin/pnpm dev"; dev.exec = "${nodePackages.pnpm}/bin/pnpm dev";
}; };
env = let
browsers = (builtins.fromJSON (builtins.readFile "${pkgs.playwright-driver}/browsers.json")).browsers;
chromium-rev = (builtins.head (builtins.filter (x: x.name == "chromium") browsers)).revision;
in {
PLAYWRIGHT_BROWSERS_PATH = "${pkgs.playwright.browsers}";
PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS = true;
PLAYWRIGHT_NODEJS_PATH = "${pkgs.nodejs}/bin/node";
PLAYWRIGHT_LAUNCH_OPTIONS_EXECUTABLE_PATH = "${pkgs.playwright.browsers}/chromium-${chromium-rev}/chrome-linux/chrome";
};
scripts.intro.exec = ''
playwrightNpmVersion="$(pnpm show @playwright/test version)"
echo " Playwright nix version: ${pkgs.playwright.version}"
echo "📦 Playwright pnpm version: $playwrightNpmVersion"
if [ "${pkgs.playwright.version}" != "$playwrightNpmVersion" ]; then
echo " Playwright versions in nix (in devenv.yaml) and pnpm (in package.json) are not the same! Please adapt the configuration."
else
echo " Playwright versions in nix and npm are the same"
fi
echo
env | grep ^PLAYWRIGHT
'';
enterShell = '' enterShell = ''
echo "🚀 Nuxt.js development environment loaded!" echo "🚀 Nuxt.js development environment loaded!"
echo "📦 Node.js version: $(node --version)" echo "📦 Node.js version: $(node --version)"
@@ -39,6 +69,7 @@ inputs.devenv.lib.mkShell {
echo "" echo ""
echo "Run 'pnpm install' to install dependencies" echo "Run 'pnpm install' to install dependencies"
echo "Run 'pnpm dev' to start the development server" echo "Run 'pnpm dev' to start the development server"
intro
''; '';
}) })
]; ];

View File

@@ -13,11 +13,8 @@
"typecheck": "nuxt typecheck", "typecheck": "nuxt typecheck",
"format": "prettier --write app/", "format": "prettier --write app/",
"format-check": "prettier --check app/", "format-check": "prettier --check app/",
"test": "pnpm test:cucumber && pnpm test:vitest", "test": "vitest",
"test:vitest": "vitest", "test:local": "vitest -c vitest.config.local.ts"
"test:cucumber": "cucumber-js",
"test:local": "pnpm test:cucumber && pnpm test:local:vitest",
"test:local:vitest": "vitest -c vitest.config.local.ts"
}, },
"dependencies": { "dependencies": {
"@iconify-json/lucide": "^1.2.74", "@iconify-json/lucide": "^1.2.74",
@@ -29,7 +26,6 @@
"pocketbase": "^0.26.5" "pocketbase": "^0.26.5"
}, },
"devDependencies": { "devDependencies": {
"@cucumber/cucumber": "^12.3.0",
"@nuxt/eslint": "^1.10.0", "@nuxt/eslint": "^1.10.0",
"@nuxt/test-utils": "3.21.0", "@nuxt/test-utils": "3.21.0",
"@pinia/nuxt": "^0.11.3", "@pinia/nuxt": "^0.11.3",
@@ -37,9 +33,10 @@
"@vitest/coverage-v8": "4.0.15", "@vitest/coverage-v8": "4.0.15",
"@vitest/ui": "^4.0.15", "@vitest/ui": "^4.0.15",
"@vue/test-utils": "^2.4.6", "@vue/test-utils": "^2.4.6",
"chai": "^6.2.1",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"happy-dom": "^20.0.11", "happy-dom": "^20.0.11",
"playwright-core": "^1.57.0", "tsx": "^4.19.2",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vitest": "^4.0.15", "vitest": "^4.0.15",
"vue-tsc": "^3.1.5" "vue-tsc": "^3.1.5"

View File

@@ -0,0 +1,36 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId('_pb_users_auth_')
// update collection data
unmarshal({
oauth2: {
enabled: true
},
otp: {
enabled: true
},
passwordAuth: {
enabled: false
}
}, collection)
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId('_pb_users_auth_')
// update collection data
unmarshal({
oauth2: {
enabled: false
},
otp: {
enabled: false
},
passwordAuth: {
enabled: true
}
}, collection)
return app.save(collection)
})

3217
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,4 +6,5 @@ ignoredBuiltDependencies:
- vue-demi - vue-demi
onlyBuiltDependencies: onlyBuiltDependencies:
- msw
- sharp - sharp