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