diff --git a/frontend/website/cypress.config.ts b/frontend/website/cypress.config.ts new file mode 100644 index 0000000..2697f45 --- /dev/null +++ b/frontend/website/cypress.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'cypress' + +const frontendPort = process.env.VITE_PORT ?? '5173' + +export default defineConfig({ + allowCypressEnv: false, + e2e: { + baseUrl: `http://127.0.0.1:${frontendPort}`, + supportFile: false, + }, + video: false, +}) diff --git a/frontend/website/cypress/e2e/guest-auth.cy.ts b/frontend/website/cypress/e2e/guest-auth.cy.ts new file mode 100644 index 0000000..f4c1ba8 --- /dev/null +++ b/frontend/website/cypress/e2e/guest-auth.cy.ts @@ -0,0 +1,78 @@ +describe('guest authentication pages', () => { + it('shows a public home route to guests', () => { + cy.visit('/') + + cy.location('pathname').should('equal', '/') + cy.get('h1').should('have.text', 'Make big goals feel possible.') + cy.contains('a', 'Log in').should('have.attr', 'href', '/login') + cy.contains('a', 'Get started').should('have.attr', 'href', '/signup') + }) + + it('redirects guests away from the protected dashboard', () => { + cy.visit('/dashboard') + + cy.location('pathname').should('equal', '/login') + cy.location('search').should('include', 'redirect=/dashboard') + }) + + it('shows the login form and links to signup', () => { + cy.visit('/login') + + cy.get('h1').should('have.text', 'Welcome back') + cy.get('label[for="login-email"]').should('have.text', 'Email address') + cy.get('#login-email').should('have.attr', 'autocomplete', 'email') + cy.get('label[for="login-password"]').should('have.text', 'Password') + cy.get('#login-password').should('have.attr', 'autocomplete', 'current-password') + cy.get('button[type="submit"]').should('have.text', 'Log in') + + cy.contains('a', 'Create an account').click() + cy.location('pathname').should('equal', '/signup') + }) + + it('shows the signup form and links to login', () => { + cy.visit('/signup') + + cy.get('h1').should('have.text', 'Start your journey') + cy.get('label[for="signup-name"]').should('have.text', 'Full name') + cy.get('#signup-name').should('have.attr', 'autocomplete', 'name') + cy.get('label[for="signup-email"]').should('have.text', 'Email address') + cy.get('#signup-email').should('have.attr', 'autocomplete', 'email') + cy.get('label[for="signup-password"]').should('have.text', 'Password') + cy.get('#signup-password').should('have.attr', 'autocomplete', 'new-password') + cy.get('label[for="signup-password-confirmation"]').should( + 'have.text', + 'Confirm password', + ) + cy.get('#signup-password-confirmation').should( + 'have.attr', + 'autocomplete', + 'new-password', + ) + cy.get('button[type="submit"]').should('have.text', 'Create account') + + cy.contains('a', 'Log in').click() + cy.location('pathname').should('equal', '/login') + }) + + it('keeps UI-only form submissions on their current route', () => { + cy.visit('/login') + cy.get('form').submit() + cy.location('pathname').should('equal', '/login') + + cy.visit('/signup') + cy.get('form').submit() + cy.location('pathname').should('equal', '/signup') + }) + + it('fits the signup page within a mobile viewport', () => { + cy.viewport(390, 844) + cy.visit('/signup') + + cy.get('main').should('be.visible') + cy.document().then((document) => { + expect(document.documentElement.scrollWidth).to.be.at.most( + document.documentElement.clientWidth, + ) + }) + }) +}) diff --git a/frontend/website/package.json b/frontend/website/package.json index 5ba881d..ebd0942 100644 --- a/frontend/website/package.json +++ b/frontend/website/package.json @@ -9,6 +9,7 @@ "preview": "vite preview", "build-only": "vite build", "type-check": "vue-tsc --build", + "test:e2e": "cypress run", "lint": "run-s \"lint:*\"", "lint:oxlint": "oxlint . --fix", "lint:eslint": "eslint . --fix --cache", diff --git a/frontend/website/src/App.vue b/frontend/website/src/App.vue index abfd315..7c2aa3f 100644 --- a/frontend/website/src/App.vue +++ b/frontend/website/src/App.vue @@ -1,11 +1,3 @@ - - - - diff --git a/frontend/website/src/components/AuthForm.vue b/frontend/website/src/components/AuthForm.vue new file mode 100644 index 0000000..709091f --- /dev/null +++ b/frontend/website/src/components/AuthForm.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/frontend/website/src/components/AuthLayout.vue b/frontend/website/src/components/AuthLayout.vue new file mode 100644 index 0000000..2eeace0 --- /dev/null +++ b/frontend/website/src/components/AuthLayout.vue @@ -0,0 +1,426 @@ + + + + + diff --git a/frontend/website/src/components/AuthTextField.vue b/frontend/website/src/components/AuthTextField.vue new file mode 100644 index 0000000..e9ec98c --- /dev/null +++ b/frontend/website/src/components/AuthTextField.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/frontend/website/src/components/BrandWordmark.vue b/frontend/website/src/components/BrandWordmark.vue new file mode 100644 index 0000000..b41aa80 --- /dev/null +++ b/frontend/website/src/components/BrandWordmark.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/frontend/website/src/main.ts b/frontend/website/src/main.ts index fda1e6e..62f3c02 100644 --- a/frontend/website/src/main.ts +++ b/frontend/website/src/main.ts @@ -3,6 +3,7 @@ import { createPinia } from 'pinia' import App from './App.vue' import router from './router' +import './styles/main.css' const app = createApp(App) diff --git a/frontend/website/src/router/index.ts b/frontend/website/src/router/index.ts index e1eab52..b726d17 100644 --- a/frontend/website/src/router/index.ts +++ b/frontend/website/src/router/index.ts @@ -1,8 +1,59 @@ import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '@/stores/auth' + const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), - routes: [], + routes: [ + { + path: '/', + name: 'home', + component: () => import('@/views/HomeView.vue'), + }, + { + path: '/login', + name: 'login', + component: () => import('@/views/LoginView.vue'), + meta: { + guestOnly: true, + }, + }, + { + path: '/signup', + name: 'signup', + component: () => import('@/views/SignupView.vue'), + meta: { + guestOnly: true, + }, + }, + { + path: '/dashboard', + name: 'dashboard', + component: () => import('@/views/DashboardView.vue'), + meta: { + requiresAuth: true, + }, + }, + ], +}) + +router.beforeEach((to) => { + const authStore = useAuthStore() + + if (to.meta.requiresAuth && !authStore.isAuthenticated) { + return { + name: 'login', + query: { + redirect: to.fullPath, + }, + } + } + + if (to.meta.guestOnly && authStore.isAuthenticated) { + return { + name: 'dashboard', + } + } }) export default router diff --git a/frontend/website/src/stores/auth.ts b/frontend/website/src/stores/auth.ts new file mode 100644 index 0000000..50688f1 --- /dev/null +++ b/frontend/website/src/stores/auth.ts @@ -0,0 +1,10 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' + +export const useAuthStore = defineStore('auth', () => { + const isAuthenticated = ref(false) + + return { + isAuthenticated, + } +}) diff --git a/frontend/website/src/styles/main.css b/frontend/website/src/styles/main.css new file mode 100644 index 0000000..a121333 --- /dev/null +++ b/frontend/website/src/styles/main.css @@ -0,0 +1,50 @@ +:root { + color: #1b2924; + background: #f3f1eb; + font-family: + Inter, + ui-sans-serif, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + min-width: 320px; + min-height: 100%; + background: #f3f1eb; +} + +body { + min-width: 320px; + min-height: 100%; + margin: 0; +} + +button, +input { + font: inherit; +} + +button, +a { + -webkit-tap-highlight-color: transparent; +} + +a { + color: inherit; +} + +#app { + min-height: 100vh; + min-height: 100svh; +} diff --git a/frontend/website/src/views/DashboardView.vue b/frontend/website/src/views/DashboardView.vue new file mode 100644 index 0000000..90b1b1d --- /dev/null +++ b/frontend/website/src/views/DashboardView.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/frontend/website/src/views/HomeView.vue b/frontend/website/src/views/HomeView.vue new file mode 100644 index 0000000..b061d02 --- /dev/null +++ b/frontend/website/src/views/HomeView.vue @@ -0,0 +1,599 @@ + + + + + diff --git a/frontend/website/src/views/LoginView.vue b/frontend/website/src/views/LoginView.vue new file mode 100644 index 0000000..e4215a4 --- /dev/null +++ b/frontend/website/src/views/LoginView.vue @@ -0,0 +1,35 @@ + + + diff --git a/frontend/website/src/views/SignupView.vue b/frontend/website/src/views/SignupView.vue new file mode 100644 index 0000000..123bf72 --- /dev/null +++ b/frontend/website/src/views/SignupView.vue @@ -0,0 +1,49 @@ + + +