diff --git a/src/App.vue b/src/App.vue index 3ed346e..cbf28b5 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,16 +1,352 @@ + + - - - diff --git a/src/router/api.ts b/src/router/api.ts new file mode 100644 index 0000000..3bd6dcc --- /dev/null +++ b/src/router/api.ts @@ -0,0 +1,80 @@ +import axios from 'axios' +import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios' + +class ApiClient { + private client: AxiosInstance + + constructor() { + this.client = axios.create({ withCredentials: true }) + } + + private getCsrfToken(): string { + let cookieValue = '' + if (document.cookie && document.cookie !== '') { + const cookies = document.cookie.split(';') + for (const rawCookie of cookies) { + const cookie = (rawCookie || '').trim() + if (cookie.startsWith('csrftoken=')) { + cookieValue = decodeURIComponent(cookie.slice('csrftoken='.length)) + break + } + } + } + return cookieValue + } + + private withCsrf(config?: AxiosRequestConfig): AxiosRequestConfig { + const token = this.getCsrfToken() + const csrfHeader = token ? { 'X-CSRFToken': token } : {} + return { + ...config, + headers: { + ...csrfHeader, + ...(config?.headers || {}), + }, + } + } + + get(url: string, config?: AxiosRequestConfig): Promise> { + return this.client.get(url, this.withCsrf(config)) + } + + post( + url: string, + data?: unknown, + config?: AxiosRequestConfig, + ): Promise> { + return this.client.post(url, data, this.withCsrf(config)) + } + + put( + url: string, + data?: unknown, + config?: AxiosRequestConfig, + ): Promise> { + return this.client.put(url, data, this.withCsrf(config)) + } + + patch( + url: string, + data?: unknown, + config?: AxiosRequestConfig, + ): Promise> { + return this.client.patch(url, data, this.withCsrf(config)) + } + + delete(url: string, config?: AxiosRequestConfig): Promise> { + return this.client.delete(url, this.withCsrf(config)) + } +} + +export const API = { + me: () => '/api/user/me/', + login: () => '/api/user/login/', + logout: () => '/api/user/logout/', + session: () => '/api/user/session/', + signup: () => '/api/user/signup/', +} + +export const apiClient = new ApiClient() +export { isAxiosError } from 'axios' diff --git a/src/router/index.ts b/src/router/index.ts index a3623b0..aa2e56e 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,8 +1,47 @@ import { createRouter, createWebHistory } from 'vue-router' +import { useUserStore } from '../stores/userStore' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), - routes: [], + routes: [ + { + path: '/', + name: 'home', + component: () => import('../views/HomeView.vue'), + }, + { + path: '/about', + name: 'about', + component: () => import('../views/AboutView.vue'), + }, + { + path: '/login', + name: 'login', + component: () => import('../views/LoginView.vue'), + meta: { guestOnly: true }, + }, + { + path: '/register', + name: 'register', + component: () => import('../views/RegisterView.vue'), + meta: { guestOnly: true }, + }, + ], +}) + +router.beforeEach((to, from, next) => { + const userStore = useUserStore() + const isAuthenticated = userStore.isAuthenticated + // const is_manager = userStore.user?.is_manager || false + + if (to.meta?.guestOnly && isAuthenticated) { + return next({ path: '/' }) + } + if (to.meta?.requiresAuth && !isAuthenticated) { + return next({ path: '/login', query: { redirect: to.fullPath } }) + } + + return next() }) export default router diff --git a/src/stores/userStore.ts b/src/stores/userStore.ts new file mode 100644 index 0000000..62e2ad5 --- /dev/null +++ b/src/stores/userStore.ts @@ -0,0 +1,159 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { apiClient, isAxiosError, API } from '../router/api' + +export interface User { + id: number + uuid: string + email_address: string + first_name: string + last_name: string + date_of_birth?: string + timezone?: string + avatar_url?: string + is_manager: boolean + created_at: string + updated_at: string +} + +export interface SessionResponse { + isAuthenticated: boolean + isStaff: boolean +} + +export const useUserStore = defineStore('user', () => { + const user = ref(null) + + const initialized = ref(false) + const loading = ref(false) + const error = ref(null) + const isAuthenticated = computed(() => Boolean(user.value)) + const displayName = computed(() => { + if (!user.value) return '' + if (user.value.first_name || user.value.last_name) { + return `${user.value.first_name || ''} ${user.value.last_name || ''}`.trim() + } + return user.value.email_address + }) + + const setUser = (value: User | null) => { + user.value = value + initialized.value = true + } + + const fetchSession = async (force = false) => { + if (initialized.value && !force) return user.value + loading.value = true + error.value = null + try { + const sessionRes = await apiClient.get(API.session()) + if (sessionRes.data?.isAuthenticated) { + const meRes = await apiClient.get(API.me()) + setUser(meRes.data) + } else { + setUser(null) + } + return user.value + } catch (err: unknown) { + if (isAxiosError(err)) { + error.value = err.response?.data?.detail || err.response?.data?.error || err.message + } else if (err instanceof Error) { + error.value = err.message + } else { + error.value = String(err) + } + setUser(null) + throw err + } finally { + loading.value = false + } + } + + const login = async (emailAddress: string, password: string) => { + loading.value = true + error.value = null + try { + const res = await apiClient.post<{ + user: User + message?: string + }>(API.login(), { email_address: emailAddress, password }) + setUser(res.data?.user ?? null) + return res.data + } catch (err: unknown) { + if (isAxiosError(err)) { + error.value = err.response?.data?.error || err.response?.data?.detail || err.message + } else if (err instanceof Error) { + error.value = err.message + } else { + error.value = String(err) + } + throw err + } finally { + loading.value = false + } + } + + const register = async (payload: { + email_address: string + password: string + confirm_password?: string + first_name: string + last_name: string + date_of_birth?: string + role?: string + }) => { + loading.value = true + error.value = null + try { + await apiClient.post(API.signup(), { + ...payload, + confirm_password: payload.confirm_password || payload.password, + }) + await login(payload.email_address, payload.password) + } catch (err: unknown) { + if (isAxiosError(err)) { + error.value = err.response?.data?.detail || err.response?.data?.error || err.message + } else if (err instanceof Error) { + error.value = err.message + } else { + error.value = String(err) + } + throw err + } finally { + loading.value = false + } + } + + const logout = async () => { + loading.value = true + error.value = null + try { + await apiClient.post(API.logout()) + } catch (err: unknown) { + if (isAxiosError(err)) { + error.value = err.response?.data?.detail || err.response?.data?.error || err.message + } else if (err instanceof Error) { + error.value = err.message + } else { + error.value = String(err) + } + throw err + } finally { + setUser(null) + loading.value = false + } + } + + return { + user, + loading, + initialized, + error, + isAuthenticated, + displayName, + fetchSession, + login, + register, + logout, + } +}) diff --git a/src/views/AboutView.vue b/src/views/AboutView.vue new file mode 100644 index 0000000..64fdc4d --- /dev/null +++ b/src/views/AboutView.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue new file mode 100644 index 0000000..86e3059 --- /dev/null +++ b/src/views/HomeView.vue @@ -0,0 +1,275 @@ + + + + + diff --git a/src/views/LoginView.vue b/src/views/LoginView.vue new file mode 100644 index 0000000..04e7113 --- /dev/null +++ b/src/views/LoginView.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/src/views/RegisterView.vue b/src/views/RegisterView.vue new file mode 100644 index 0000000..7d67b66 --- /dev/null +++ b/src/views/RegisterView.vue @@ -0,0 +1,169 @@ + + + + +