├── .npmrc ├── src ├── app.css ├── lib │ ├── index.ts │ ├── helpers │ │ ├── generateCookieString.ts │ │ └── generateCookieString.test.ts │ ├── types │ │ └── user.ts │ └── components │ │ └── icons │ │ ├── User.svelte │ │ ├── XMark.svelte │ │ ├── Lock.svelte │ │ ├── CommercialAt.svelte │ │ ├── Link.svelte │ │ ├── Home.svelte │ │ ├── Paper.svelte │ │ ├── Fingerprint.svelte │ │ └── Tag.svelte ├── demo.spec.ts ├── routes │ ├── (auth) │ │ ├── +layout.server.ts │ │ ├── dashboard │ │ │ └── +page.svelte │ │ └── +layout.svelte │ ├── page.svelte.test.ts │ ├── +page.svelte │ ├── logout │ │ └── +page.server.ts │ ├── +layout.svelte │ ├── login │ │ ├── +page.server.ts │ │ └── +page.svelte │ └── register │ │ ├── +page.server.ts │ │ └── +page.svelte ├── app.html ├── app.d.ts └── hooks.server.ts ├── .env.example ├── static └── favicon.png ├── .prettierignore ├── .eslintignore ├── playwright.config.ts ├── svelte.config.js ├── .prettierrc ├── .gitignore ├── vitest-setup-client.ts ├── tsconfig.json ├── .eslintrc.cjs ├── e2e ├── login.spec.ts └── page.spec.ts ├── vite.config.ts ├── package.json └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # URL can't be end with '/' 2 | API_URL=http://laravel-api.test -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | // place files you want to import through the `$lib` alias in this folder. 2 | -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yilanboy/sveltekit-with-laravel/main/static/favicon.png -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Package Managers 2 | package-lock.json 3 | pnpm-lock.yaml 4 | yarn.lock 5 | bun.lock 6 | bun.lockb 7 | -------------------------------------------------------------------------------- /src/demo.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | 3 | describe('sum test', () => { 4 | it('adds 1 + 2 to equal 3', () => { 5 | expect(1 + 2).toBe(3); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/routes/(auth)/+layout.server.ts: -------------------------------------------------------------------------------- 1 | import type { LayoutServerLoad } from './$types'; 2 | 3 | export const load: LayoutServerLoad = async ({ locals }) => { 4 | return { 5 | user: locals.user 6 | }; 7 | }; 8 | -------------------------------------------------------------------------------- /src/routes/(auth)/dashboard/+page.svelte: -------------------------------------------------------------------------------- 1 | 2 | 儀表板 3 | 4 | 5 |
6 |

您好!

7 |
8 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from '@playwright/test'; 2 | 3 | export default defineConfig({ 4 | webServer: { 5 | command: 'npm run build && npm run preview', 6 | port: 4173 7 | }, 8 | testDir: 'e2e' 9 | }); 10 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-auto'; 2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 | 4 | const config = { 5 | preprocess: vitePreprocess(), 6 | kit: { adapter: adapter() } 7 | }; 8 | 9 | export default config; 10 | -------------------------------------------------------------------------------- /src/lib/helpers/generateCookieString.ts: -------------------------------------------------------------------------------- 1 | export default function generateCookieString(cookies: { name: string; value: string }[]): string { 2 | let cookie_string = ''; 3 | 4 | cookies.forEach((cookie) => { 5 | cookie_string += `${cookie.name}=${cookie.value};`; 6 | }); 7 | 8 | return cookie_string; 9 | } 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], 7 | "overrides": [ 8 | { 9 | "files": "*.svelte", 10 | "options": { 11 | "parser": "svelte" 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/types/user.ts: -------------------------------------------------------------------------------- 1 | // Don't put your types in .d.ts files 2 | // https://www.youtube.com/watch?v=zu-EgnbmcLY 3 | 4 | export type Auth = { 5 | id: number; 6 | name: string; 7 | email: string; 8 | email_verified_at: string; 9 | created_at: string; 10 | updated_at: string; 11 | }; 12 | 13 | export type Guest = { 14 | message: 'Unauthenticated.'; 15 | }; 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | test-results 2 | node_modules 3 | 4 | # Output 5 | .output 6 | .vercel 7 | .netlify 8 | .wrangler 9 | /.svelte-kit 10 | /build 11 | 12 | # OS 13 | .DS_Store 14 | Thumbs.db 15 | 16 | # Env 17 | .env 18 | .env.* 19 | !.env.example 20 | !.env.test 21 | 22 | # Vite 23 | vite.config.js.timestamp-* 24 | vite.config.ts.timestamp-* 25 | 26 | # IDE 27 | .idea 28 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/routes/page.svelte.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest'; 2 | import '@testing-library/jest-dom/vitest'; 3 | import { render, screen } from '@testing-library/svelte'; 4 | import Page from './+page.svelte'; 5 | 6 | describe('/+page.svelte', () => { 7 | test('should render h1', () => { 8 | render(Page); 9 | expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | import type { Auth, Guest } from '$lib/types/user'; 2 | 3 | // See https://kit.svelte.dev/docs/types#app 4 | // for information about these interfaces 5 | declare global { 6 | namespace App { 7 | // interface Error {} 8 | interface Locals { 9 | user: Auth | Guest; 10 | } 11 | // interface PageData {} 12 | // interface PageState {} 13 | // interface Platform {} 14 | } 15 | } 16 | 17 | export {}; 18 | -------------------------------------------------------------------------------- /src/lib/components/icons/User.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 15 | 16 | -------------------------------------------------------------------------------- /src/lib/components/icons/XMark.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/lib/components/icons/Lock.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 15 | 16 | -------------------------------------------------------------------------------- /src/lib/helpers/generateCookieString.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import generateCookieString from './generateCookieString'; 3 | 4 | describe('Cookie helper', () => { 5 | it('can generate the cookie string', () => { 6 | const cookies = [ 7 | { 8 | name: 'cookie-1', 9 | value: 'apple' 10 | }, 11 | { 12 | name: 'cookie-2', 13 | value: 'banana' 14 | } 15 | ]; 16 | 17 | expect(generateCookieString(cookies)).toBe('cookie-1=apple;cookie-2=banana;'); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /vitest-setup-client.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/vitest'; 2 | import { vi } from 'vitest'; 3 | 4 | // required for svelte5 + jsdom as jsdom does not support matchMedia 5 | Object.defineProperty(window, 'matchMedia', { 6 | writable: true, 7 | enumerable: true, 8 | value: vi.fn().mockImplementation((query) => ({ 9 | matches: false, 10 | media: query, 11 | onchange: null, 12 | addEventListener: vi.fn(), 13 | removeEventListener: vi.fn(), 14 | dispatchEvent: vi.fn() 15 | })) 16 | }); 17 | 18 | // add more mocks here if you need them 19 | -------------------------------------------------------------------------------- /src/lib/components/icons/CommercialAt.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 16 | 22 | 23 | -------------------------------------------------------------------------------- /src/lib/components/icons/Link.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 17 | 22 | 23 | -------------------------------------------------------------------------------- /src/lib/components/icons/Home.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 17 | 22 | 23 | -------------------------------------------------------------------------------- /src/lib/components/icons/Paper.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 17 | 22 | 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "moduleResolution": "bundler" 13 | } 14 | // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias 15 | // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files 16 | // 17 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 18 | // from the referenced tsconfig.json - TypeScript does not merge them in 19 | } 20 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type { import("eslint").Linter.Config } */ 2 | module.exports = { 3 | root: true, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:svelte/recommended', 8 | 'prettier' 9 | ], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['@typescript-eslint'], 12 | parserOptions: { 13 | sourceType: 'module', 14 | ecmaVersion: 2020, 15 | extraFileExtensions: ['.svelte'] 16 | }, 17 | env: { 18 | browser: true, 19 | es2017: true, 20 | node: true 21 | }, 22 | overrides: [ 23 | { 24 | files: ['*.svelte'], 25 | parser: 'svelte-eslint-parser', 26 | parserOptions: { 27 | parser: '@typescript-eslint/parser' 28 | } 29 | } 30 | ] 31 | }; 32 | -------------------------------------------------------------------------------- /src/lib/components/icons/Fingerprint.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 16 | 22 | 23 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 |
2 |

3 | 歡迎來到 SvelteKit & 4 | Laravel 5 | 範例 6 |

7 |
8 | 12 | 登入 → 13 | 14 | 18 | 註冊 → 19 | 20 |
21 |
22 | -------------------------------------------------------------------------------- /src/lib/components/icons/Tag.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 17 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/routes/logout/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from '@sveltejs/kit'; 2 | import type { Actions, PageServerLoad } from './$types'; 3 | import generateCookieString from '$lib/helpers/generateCookieString'; 4 | import { API_URL } from '$env/static/private'; 5 | 6 | export const load: PageServerLoad = async () => { 7 | // we only use this endpoint for the api 8 | // and don't need to see the page 9 | redirect(302, '/'); 10 | }; 11 | 12 | export const actions = { 13 | default: async ({ fetch, cookies }) => { 14 | const cookieString: string = generateCookieString(cookies.getAll()); 15 | 16 | await fetch(`${API_URL}/logout`, { 17 | method: 'POST', 18 | headers: new Headers({ 19 | 'X-XSRF-TOKEN': cookies.get('XSRF-TOKEN') ?? '', 20 | Cookie: cookieString 21 | }) 22 | }); 23 | 24 | redirect(303, '/login'); 25 | } 26 | } satisfies Actions; 27 | -------------------------------------------------------------------------------- /e2e/login.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test'; 2 | 3 | test('user can login', async ({ page }) => { 4 | await page.goto('/login'); 5 | 6 | await page.fill('#email', 'allen@email.com'); 7 | await page.fill('#password', 'Password101'); 8 | 9 | await page.click('button[type="submit"]'); 10 | 11 | await page.waitForURL('/dashboard'); 12 | 13 | expect(page.url()).toBe('http://localhost:4173/dashboard'); 14 | }); 15 | 16 | test("user can't login if email or password is wrong", async ({ page }) => { 17 | await page.goto('/login'); 18 | 19 | await page.fill('#email', 'allen@email.com'); 20 | await page.fill('#password', 'WrongPassword101'); 21 | 22 | await page.click('button[type="submit"]'); 23 | 24 | const errorMessage = page.locator('text=使用者名稱或密碼錯誤。'); 25 | await errorMessage.waitFor(); 26 | 27 | expect(await errorMessage.isVisible()).toBeTruthy(); 28 | }); 29 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import tailwindcss from '@tailwindcss/vite'; 2 | import { svelteTesting } from '@testing-library/svelte/vite'; 3 | import { sveltekit } from '@sveltejs/kit/vite'; 4 | import { defineConfig } from 'vite'; 5 | 6 | export default defineConfig({ 7 | plugins: [tailwindcss(), sveltekit()], 8 | test: { 9 | workspace: [ 10 | { 11 | extends: './vite.config.ts', 12 | plugins: [svelteTesting()], 13 | test: { 14 | name: 'client', 15 | environment: 'jsdom', 16 | clearMocks: true, 17 | include: ['src/**/*.svelte.{test,spec}.{js,ts}'], 18 | exclude: ['src/lib/server/**'], 19 | setupFiles: ['./vitest-setup-client.ts'] 20 | } 21 | }, 22 | { 23 | extends: './vite.config.ts', 24 | test: { 25 | name: 'server', 26 | environment: 'node', 27 | include: ['src/**/*.{test,spec}.{js,ts}'], 28 | exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'] 29 | } 30 | } 31 | ] 32 | } 33 | }); 34 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 | {@render children?.()} 23 | 24 | 25 | 26 | 27 | 28 | 32 | 33 | -------------------------------------------------------------------------------- /e2e/page.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test'; 2 | 3 | test('welcome page can be visited', async ({ page }) => { 4 | const response = await page.request.get('/'); 5 | 6 | await expect(response).toBeOK(); 7 | }); 8 | 9 | test('welcome page has a heading', async ({ page }) => { 10 | await page.goto('/'); 11 | 12 | await expect( 13 | page.getByRole('heading', { name: '歡迎來到 SvelteKit & Laravel 範例' }) 14 | ).toBeVisible(); 15 | }); 16 | 17 | test('welcome page has a login page link', async ({ page }) => { 18 | await page.goto('/'); 19 | 20 | await expect(page.getByRole('link', { name: '登入' })).toBeVisible(); 21 | }); 22 | 23 | test('welcome page has a register page link', async ({ page }) => { 24 | await page.goto('/'); 25 | 26 | await expect(page.getByRole('link', { name: '註冊' })).toBeVisible(); 27 | }); 28 | 29 | test('redirect to login page', async ({ page }) => { 30 | await page.goto('/'); 31 | 32 | await page.getByRole('link', { name: '登入' }).click(); 33 | 34 | await page.waitForURL('/login'); 35 | 36 | expect(page.url()).toContain('/login'); 37 | }); 38 | 39 | test('redirect to register page', async ({ page }) => { 40 | await page.goto('/'); 41 | 42 | await page.getByRole('link', { name: '註冊' }).click(); 43 | 44 | await page.waitForURL('/register'); 45 | 46 | expect(page.url()).toContain('/register'); 47 | }); 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelte-with-laravel", 3 | "private": true, 4 | "version": "0.0.1", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite dev", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "prepare": "svelte-kit sync || echo ''", 11 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 12 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 13 | "format": "prettier --write .", 14 | "lint": "prettier --check . && eslint .", 15 | "test:unit": "vitest", 16 | "test": "npm run test:unit -- --run && npm run test:e2e", 17 | "test:e2e": "playwright test" 18 | }, 19 | "devDependencies": { 20 | "@eslint/compat": "^1.2.5", 21 | "@eslint/js": "^9.18.0", 22 | "@playwright/test": "^1.49.1", 23 | "@sveltejs/adapter-auto": "^6.0.0", 24 | "@sveltejs/kit": "^2.16.0", 25 | "@sveltejs/vite-plugin-svelte": "^5.0.0", 26 | "@tailwindcss/vite": "^4.0.0", 27 | "@testing-library/jest-dom": "^6.6.3", 28 | "@testing-library/svelte": "^5.2.4", 29 | "eslint": "^9.18.0", 30 | "eslint-config-prettier": "^10.0.1", 31 | "eslint-plugin-svelte": "^3.0.0", 32 | "globals": "^16.0.0", 33 | "jsdom": "^26.0.0", 34 | "prettier": "^3.4.2", 35 | "prettier-plugin-svelte": "^3.3.3", 36 | "prettier-plugin-tailwindcss": "^0.6.11", 37 | "svelte": "^5.0.0", 38 | "svelte-check": "^4.0.0", 39 | "tailwindcss": "^4.0.0", 40 | "typescript": "^5.0.0", 41 | "typescript-eslint": "^8.20.0", 42 | "vite": "^6.2.6", 43 | "vitest": "3.1.4" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SvelteKit with Laravel (WIP) 2 | 3 | A SvelteKit template project show how to use Laravel as a backend. 4 | 5 | > [!IMPORTANT] 6 | > 7 | > The authentication use Laravel official package - [Laravel Sanctum](https://laravel.com/docs/11.x/sanctum), this package provides the cookie-based authentication. 8 | > 9 | > The project is simple at the moment, with only login and route protection. I'm currently using it to develop my blog CMS. Still work in progress. 10 | 11 | ## Installation 12 | 13 | This is a template project, you can click the `Use this template` to fork the project in your account. 14 | 15 | Then, clone the project. 16 | 17 | ```bash 18 | git clone git@github.com:/sveltekit-with-laravel.git 19 | ``` 20 | 21 | Move into the project, and create a new `.env` file. 22 | 23 | ```bash 24 | cd sveltekit-with-laravel 25 | cp .env.example .env 26 | ``` 27 | 28 | Install the npm packages. 29 | 30 | ```bash 31 | npm install 32 | ``` 33 | 34 | Start the local website. 35 | 36 | ```bash 37 | npm run dev -- --open --port 3000 38 | ``` 39 | 40 | I suggest using 80 or 3000 as the default port. 41 | 42 | Because we use the cookie-based authentication, it means request should be **STATEFUL**. 43 | In the Laravel Sanctum default settings, there are only few domains allow to be stateful. 44 | 45 | ```php 46 | // config/sanctum.php 47 | 48 | return [ 49 | // ... 50 | 51 | 'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( 52 | '%s%s%s', 53 | 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', 54 | Sanctum::currentApplicationUrlWithPort(), 55 | // Although you can set the FRONTEND_URL, the port is removed here. 56 | env('FRONTEND_URL') 57 | ? ','.parse_url(env('FRONTEND_URL'), PHP_URL_HOST) 58 | : '' 59 | ))), 60 | 61 | // ... 62 | ] 63 | ``` 64 | 65 | You can also update the Laravel Sanctum config and make 5173 port be stateful. It's OK. 66 | -------------------------------------------------------------------------------- /src/routes/login/+page.server.ts: -------------------------------------------------------------------------------- 1 | import type { Actions } from './$types'; 2 | import { fail, redirect } from '@sveltejs/kit'; 3 | import { API_URL } from '$env/static/private'; 4 | import generateCookieString from '$lib/helpers/generateCookieString'; 5 | import cookie from 'cookie'; 6 | 7 | export const actions = { 8 | default: async ({ request, cookies, fetch }) => { 9 | const data = await request.formData(); 10 | const email = data.get('email'); 11 | const password = data.get('password'); 12 | 13 | if (!email) { 14 | return fail(400, { email, error: true, message: '請輸入信箱' }); 15 | } 16 | 17 | if (!password) { 18 | return fail(400, { email, error: true, message: '請輸入密碼' }); 19 | } 20 | 21 | const cookieString: string = generateCookieString(cookies.getAll()); 22 | 23 | const loginResponse = await fetch(`${API_URL}/login`, { 24 | method: 'POST', 25 | headers: new Headers({ 26 | Accept: 'application/json', 27 | 'Content-Type': 'application/json', 28 | 'X-XSRF-TOKEN': cookies.get('XSRF-TOKEN') ?? '', 29 | Cookie: cookieString 30 | }), 31 | body: JSON.stringify({ 32 | email: data.get('email'), 33 | password: data.get('password'), 34 | remember: data.get('remember') 35 | }) 36 | }); 37 | 38 | if (loginResponse.status !== 204) { 39 | const loginResponseJson = await loginResponse.json(); 40 | 41 | return fail(400, { email, error: true, message: loginResponseJson.message }); 42 | } 43 | 44 | const setCookies = loginResponse.headers.getSetCookie(); 45 | 46 | for (const setCookie of setCookies) { 47 | const record = cookie.parse(setCookie); 48 | const cookieName: string = Object.keys(record)[0]; 49 | 50 | cookies.set(cookieName, record[cookieName], { 51 | httpOnly: true, 52 | maxAge: parseInt(record['Max-Age'] ?? '7200'), 53 | path: record['path'] ?? '/', 54 | sameSite: record['samesite'] as boolean | 'lax' | 'strict' | 'none' | undefined 55 | }); 56 | } 57 | 58 | redirect(303, '/dashboard'); 59 | } 60 | } satisfies Actions; 61 | -------------------------------------------------------------------------------- /src/hooks.server.ts: -------------------------------------------------------------------------------- 1 | import { API_URL } from '$env/static/private'; 2 | import { redirect } from '@sveltejs/kit'; 3 | import cookie from 'cookie'; 4 | import type { Auth, Guest } from '$lib/types/user'; 5 | 6 | function isAuthenticated(user: Auth | Guest): user is Auth { 7 | return ( 8 | (user as Auth).id !== undefined && 9 | (user as Auth).name !== undefined && 10 | (user as Auth).email !== undefined && 11 | (user as Auth).email_verified_at !== undefined && 12 | (user as Auth).created_at !== undefined && 13 | (user as Auth).updated_at !== undefined 14 | ); 15 | } 16 | 17 | export async function handle({ event, resolve }) { 18 | const cookieString: string = event.request.headers.get('cookie') ?? ''; 19 | const routeId: string = event.route.id ?? ''; 20 | 21 | const userResponse = await event.fetch(`${API_URL}/api/user`, { 22 | method: 'GET', 23 | headers: { 24 | Accept: 'application/json', 25 | Cookie: cookieString 26 | } 27 | }); 28 | 29 | // user api will also return the csrf token, so we don't need to use csrf token api 30 | // set cookies in every navigation and page refresh 31 | // this action can make sure front-end will always have laravel session and xsrf token 32 | const setCookies = userResponse.headers.getSetCookie(); 33 | 34 | for (const setCookie of setCookies) { 35 | const record = cookie.parse(setCookie); 36 | const cookieName: string = Object.keys(record)[0]; 37 | 38 | event.cookies.set(cookieName, record[cookieName], { 39 | httpOnly: true, 40 | maxAge: parseInt(record['Max-Age'] ?? '7200'), 41 | path: record['path'] ?? '/', 42 | sameSite: record['samesite'] as boolean | 'lax' | 'strict' | 'none' | undefined 43 | }); 44 | } 45 | 46 | event.locals.user = await userResponse.json(); 47 | 48 | if (routeId.includes('/login') && isAuthenticated(event.locals.user)) { 49 | redirect(303, '/dashboard'); 50 | } 51 | 52 | if (routeId.includes('/register') && isAuthenticated(event.locals.user)) { 53 | redirect(303, '/dashboard'); 54 | } 55 | 56 | if (routeId.includes('/(auth)/') && !isAuthenticated(event.locals.user)) { 57 | redirect(303, '/login'); 58 | } 59 | 60 | return resolve(event); 61 | } 62 | -------------------------------------------------------------------------------- /src/routes/register/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { API_URL } from '$env/static/private'; 2 | import { fail, redirect } from '@sveltejs/kit'; 3 | import generateCookieString from '$lib/helpers/generateCookieString'; 4 | import type { Actions } from './$types'; 5 | import cookie from 'cookie'; 6 | 7 | export const actions = { 8 | default: async ({ request, cookies, fetch }) => { 9 | const data = await request.formData(); 10 | const name = data.get('name'); 11 | const email = data.get('email'); 12 | const password = data.get('password'); 13 | const password_confirmation = data.get('password_confirmation'); 14 | 15 | if (!name) { 16 | return fail(400, { name, email, error: true, message: '請輸入名稱' }); 17 | } 18 | 19 | if (!email) { 20 | return fail(400, { name, email, error: true, message: '請輸入信箱' }); 21 | } 22 | 23 | if (!password) { 24 | return fail(400, { name, email, error: true, message: '請輸入密碼' }); 25 | } 26 | 27 | if (!password_confirmation) { 28 | return fail(400, { name, email, error: true, message: '請輸入密碼確認' }); 29 | } 30 | 31 | if (password !== password_confirmation) { 32 | return fail(400, { name, email, error: true, message: '密碼確認欄位的輸入不一致' }); 33 | } 34 | 35 | const cookieString: string = generateCookieString(cookies.getAll()); 36 | 37 | const registerResponse = await fetch(`${API_URL}/register`, { 38 | method: 'POST', 39 | headers: new Headers({ 40 | Accept: 'application/json', 41 | 'Content-Type': 'application/json', 42 | 'X-XSRF-TOKEN': cookies.get('XSRF-TOKEN') ?? '', 43 | Cookie: cookieString 44 | }), 45 | body: JSON.stringify({ 46 | name: data.get('name'), 47 | email: data.get('email'), 48 | password: data.get('password'), 49 | password_confirmation: data.get('password_confirmation') 50 | }) 51 | }); 52 | 53 | if (registerResponse.status !== 204) { 54 | const registerResponseJson = await registerResponse.json(); 55 | return fail(400, { name, email, error: true, message: registerResponseJson.message }); 56 | } 57 | 58 | const setCookies = registerResponse.headers.getSetCookie(); 59 | 60 | for (const setCookie of setCookies) { 61 | const record = cookie.parse(setCookie); 62 | const cookieName: string = Object.keys(record)[0]; 63 | 64 | cookies.set(cookieName, record[cookieName], { 65 | httpOnly: true, 66 | maxAge: parseInt(record['Max-Age'] ?? '7200'), 67 | path: record['path'] ?? '/', 68 | sameSite: record['samesite'] as boolean | 'lax' | 'strict' | 'none' | undefined 69 | }); 70 | } 71 | 72 | redirect(303, '/dashboard'); 73 | } 74 | } satisfies Actions; 75 | -------------------------------------------------------------------------------- /src/routes/login/+page.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | 登入 15 | 16 | 17 |
18 | 31 |
32 |
33 | {#if form?.error} 34 |

{form?.message}

35 | {/if} 36 |

登入

37 |

歡迎回來,讓我們開始吧

38 |
39 | 40 | 41 | 49 |
50 |
51 | 52 | 53 | 60 |
61 | 66 |
67 | 76 | 77 | 80 | 忘記密碼? 81 | 82 |
83 |
84 |
85 |
86 | -------------------------------------------------------------------------------- /src/routes/register/+page.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 註冊 17 | 18 | 19 |
20 | 33 |
34 |
35 | {#if form?.error} 36 |

{form?.message}

37 | {/if} 38 |

註冊

39 |

歡迎你來,很高興認識你

40 |
41 | 42 | 50 |
51 |
52 | 53 | 61 |
62 |
63 | 64 | 71 |
72 |
73 | 74 | 81 |
82 | 88 |
89 |
90 |
91 | -------------------------------------------------------------------------------- /src/routes/(auth)/+layout.svelte: -------------------------------------------------------------------------------- 1 | 53 | 54 |
55 | 56 | 120 | 121 | 122 | 155 | 156 |
157 |
160 | 177 | 178 | 179 | 180 | 181 |
182 |
183 | 184 | 196 | 203 |
204 |
205 | 222 | 223 | 224 | 225 | 226 | 227 |
228 | 260 | 261 | 262 | 298 |
299 |
300 |
301 |
302 | 303 |
304 |
305 | 306 | {@render children?.()} 307 |
308 |
309 |
310 |
311 | --------------------------------------------------------------------------------