diff --git a/backend/phpunit.xml b/backend/phpunit.xml index e7f0a48..dafc3f1 100644 --- a/backend/phpunit.xml +++ b/backend/phpunit.xml @@ -26,6 +26,7 @@ + diff --git a/frontend/website/cypress.config.ts b/frontend/website/cypress.config.ts index 2697f45..4a2d276 100644 --- a/frontend/website/cypress.config.ts +++ b/frontend/website/cypress.config.ts @@ -5,7 +5,7 @@ const frontendPort = process.env.VITE_PORT ?? '5173' export default defineConfig({ allowCypressEnv: false, e2e: { - baseUrl: `http://127.0.0.1:${frontendPort}`, + baseUrl: `https://localhost:${frontendPort}`, supportFile: false, }, video: false, diff --git a/frontend/website/env.d.ts b/frontend/website/env.d.ts index 11f02fe..b54b4c9 100644 --- a/frontend/website/env.d.ts +++ b/frontend/website/env.d.ts @@ -1 +1,9 @@ /// + +interface ImportMetaEnv { + readonly VITE_API_URL: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/frontend/website/package-lock.json b/frontend/website/package-lock.json index 6cbfc4d..e47680a 100644 --- a/frontend/website/package-lock.json +++ b/frontend/website/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "pinia": "^4.0.2", "vue": "^3.5.40", - "vue-router": "^5.2.0" + "vue-router": "^5.2.0", + "zod": "^4.4.3" }, "devDependencies": { "@tsconfig/node24": "^24.0.4", @@ -7620,6 +7621,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/frontend/website/package.json b/frontend/website/package.json index ebd0942..e70648e 100644 --- a/frontend/website/package.json +++ b/frontend/website/package.json @@ -18,7 +18,8 @@ "dependencies": { "pinia": "^4.0.2", "vue": "^3.5.40", - "vue-router": "^5.2.0" + "vue-router": "^5.2.0", + "zod": "^4.4.3" }, "devDependencies": { "@tsconfig/node24": "^24.0.4", diff --git a/frontend/website/src/router/index.ts b/frontend/website/src/router/index.ts index b726d17..51e7184 100644 --- a/frontend/website/src/router/index.ts +++ b/frontend/website/src/router/index.ts @@ -37,21 +37,31 @@ const router = createRouter({ ], }) -router.beforeEach((to) => { +router.beforeEach(async (to) => { const authStore = useAuthStore() if (to.meta.requiresAuth && !authStore.isAuthenticated) { - return { - name: 'login', - query: { - redirect: to.fullPath, - }, + const restoredSession = await authStore.fetchMe() + if (!restoredSession) { + return { + name: 'login', + query: { + redirect: to.fullPath, + }, + } } } - if (to.meta.guestOnly && authStore.isAuthenticated) { - return { - name: 'dashboard', + if (to.meta.guestOnly) { + let restoredSession = authStore.isAuthenticated + if (!restoredSession) { + restoredSession = await authStore.fetchMe() + } + + if (restoredSession) { + return { + name: 'dashboard', + } } } }) diff --git a/frontend/website/src/stores/auth.ts b/frontend/website/src/stores/auth.ts index 50688f1..399a301 100644 --- a/frontend/website/src/stores/auth.ts +++ b/frontend/website/src/stores/auth.ts @@ -1,10 +1,67 @@ -import { ref } from 'vue' +import { computed, ref } from 'vue' import { defineStore } from 'pinia' +import { z } from 'zod' + +import { API_BASE } from '@/utils/apiBase' + +export const authUserSchema = z.object({ + id: z.number().int().positive(), + email: z.string().email(), +}) + +const meResponseSchema = z.object({ + user: authUserSchema, +}) + +export type AuthUser = z.infer export const useAuthStore = defineStore('auth', () => { - const isAuthenticated = ref(false) + const user = ref(null) + const loading = ref(false) + const error = ref(null) + const isAuthenticated = computed(() => user.value !== null) + + async function fetchMe(): Promise { + loading.value = true + error.value = null + + try { + const response = await fetch(`${API_BASE}/api/me`, { + method: 'GET', + credentials: 'include', + headers: { + Accept: 'application/json', + }, + }) + + if (response.status === 200) { + const responseBody: unknown = await response.json() + user.value = meResponseSchema.parse(responseBody).user + + return true + } + + user.value = null + if (response.status !== 401) { + error.value = 'Unable to restore session' + } + + return false + } catch { + user.value = null + error.value = 'Unable to restore session' + + return false + } finally { + loading.value = false + } + } return { + user, + loading, + error, isAuthenticated, + fetchMe, } }) diff --git a/frontend/website/src/utils/apiBase.ts b/frontend/website/src/utils/apiBase.ts new file mode 100644 index 0000000..0a1babc --- /dev/null +++ b/frontend/website/src/utils/apiBase.ts @@ -0,0 +1,17 @@ +function resolveApiBase(): string { + const configuredApiUrl = import.meta.env.VITE_API_URL + if (configuredApiUrl === '') { + throw new Error('VITE_API_URL must be configured') + } + + if (!import.meta.env.DEV) { + return configuredApiUrl + } + + const apiUrl = new URL(configuredApiUrl) + apiUrl.hostname = window.location.hostname + + return apiUrl.origin +} + +export const API_BASE = resolveApiBase() diff --git a/frontend/website/vite.config.ts b/frontend/website/vite.config.ts index ace127e..a6fdcaa 100644 --- a/frontend/website/vite.config.ts +++ b/frontend/website/vite.config.ts @@ -1,12 +1,30 @@ +import { existsSync, readFileSync } from 'node:fs' import { fileURLToPath, URL } from 'node:url' import { defineConfig } from 'vite' +import type { ServerOptions } from 'vite' import vue from '@vitejs/plugin-vue' import vueJsx from '@vitejs/plugin-vue-jsx' import vueDevTools from 'vite-plugin-vue-devtools' +const certificateKeyPath = fileURLToPath( + new URL('../../.cert/localhost-key.pem', import.meta.url), +) +const certificatePath = fileURLToPath( + new URL('../../.cert/localhost.pem', import.meta.url), +) +const serverOptions: ServerOptions = {} + +if (existsSync(certificateKeyPath) && existsSync(certificatePath)) { + serverOptions.https = { + key: readFileSync(certificateKeyPath), + cert: readFileSync(certificatePath), + } +} + // https://vite.dev/config/ export default defineConfig({ + server: serverOptions, plugins: [ vue(), vueJsx(), diff --git a/nix/shell-hook.sh b/nix/shell-hook.sh index d835131..73d1874 100644 --- a/nix/shell-hook.sh +++ b/nix/shell-hook.sh @@ -38,6 +38,7 @@ export PGUSER="postgres" export PGDATABASE="postgres" DEV_APP_URL="https://localhost:$CADDY_PORT" +DEV_FRONTEND_URL="https://localhost:$VITE_PORT" DEV_DB_CONNECTION="pgsql" DEV_DB_HOST="$PGHOST" DEV_DB_PORT="5432" @@ -99,6 +100,7 @@ set_env_value() { } set_env_value APP_URL "$DEV_APP_URL" +set_env_value FRONTEND_URL "$DEV_FRONTEND_URL" set_env_value DB_CONNECTION "$DEV_DB_CONNECTION" set_env_value DB_HOST "$DEV_DB_HOST" set_env_value DB_PORT "$DEV_DB_PORT" @@ -109,6 +111,13 @@ set_env_value MAIL_MAILER "$DEV_MAIL_MAILER" set_env_value MAIL_HOST "$DEV_MAIL_HOST" set_env_value MAIL_PORT "$DEV_MAIL_PORT" +FRONTEND_ENV_FILE="$REPO_ROOT/frontend/website/.env.local" +FRONTEND_API_URL="VITE_API_URL=$DEV_APP_URL" +if [ ! -f "$FRONTEND_ENV_FILE" ] \ + || [ "$(cat "$FRONTEND_ENV_FILE")" != "$FRONTEND_API_URL" ]; then + printf '%s\n' "$FRONTEND_API_URL" > "$FRONTEND_ENV_FILE" +fi + if [ ! -d "$REPO_ROOT/backend/vendor" ]; then echo "[composer] installing backend dependencies" (cd "$REPO_ROOT/backend" && composer install) diff --git a/process-compose.yaml b/process-compose.yaml index 2a00aea..fcddf50 100644 --- a/process-compose.yaml +++ b/process-compose.yaml @@ -63,6 +63,7 @@ processes: host: 127.0.0.1 port: ${VITE_PORT:-5173} path: / + scheme: https initial_delay_seconds: 1 period_seconds: 2