├── .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 |
16 |
--------------------------------------------------------------------------------
/src/lib/components/icons/XMark.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 |
19 |
--------------------------------------------------------------------------------
/src/lib/components/icons/Lock.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 |
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 |
23 |
--------------------------------------------------------------------------------
/src/lib/components/icons/Link.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 |
23 |
--------------------------------------------------------------------------------
/src/lib/components/icons/Home.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 |
23 |
--------------------------------------------------------------------------------
/src/lib/components/icons/Paper.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 |
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 |
23 |
--------------------------------------------------------------------------------
/src/routes/+page.svelte:
--------------------------------------------------------------------------------
1 |
2 |
3 | 歡迎來到 SvelteKit &
4 | Laravel
5 | 範例
6 |
7 |
21 |
22 |
--------------------------------------------------------------------------------
/src/lib/components/icons/Tag.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 |
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 |
86 |
--------------------------------------------------------------------------------
/src/routes/register/+page.svelte:
--------------------------------------------------------------------------------
1 |
14 |
15 |
16 | 註冊
17 |
18 |
19 |
91 |
--------------------------------------------------------------------------------
/src/routes/(auth)/+layout.svelte:
--------------------------------------------------------------------------------
1 |
53 |
54 |
55 |
56 |
57 |
58 |
64 |
65 |
119 |
120 |
121 |
122 |
123 |
124 |
127 |
{menuTitle}
128 |
153 |
154 |
155 |
156 |
157 |
160 |
177 |
178 |
179 |
180 |
181 |
182 |
204 |
205 |
222 |
223 |
224 |
225 |
226 |
227 |
228 |
260 |
261 |
262 |
278 |
279 |
286 |
287 |
297 |
298 |
299 |
300 |
301 |
302 |
303 |
304 |
305 |
306 | {@render children?.()}
307 |
308 |
309 |
310 |
311 |
--------------------------------------------------------------------------------