使用 Vue 3 和 Express 實作內容管理系統(十一):實作前端認證功能

前言

本文是前端工作坊的教學文件,介紹如何使用 Vue 3 和 Express 實作內容管理系統,並搭配 Firebase 實現持久化和認證。

開啟專案

開啟前端專案。

1
2
cd simple-cms-ui
code .

實作認證模組

Ref: https://firebase.google.com/docs/web/setup?hl=zh-tw#add-sdk-and-initialize

安裝依賴套件。

1
npm install firebase

新增 src/firebase/app.js 檔,嘗試註冊一個使用者。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { initializeApp } from 'firebase/app';
import { createUserWithEmailAndPassword, getAuth } from 'firebase/auth';

// Your web app's Firebase configuration
const firebaseConfig = {
// ...
};

const app = initializeApp(firebaseConfig);

const auth = getAuth(app);

export const signUp = ({ email, password }) => createUserWithEmailAndPassword(auth, email, password);

signUp({ email: 'test@example.com', password: 'secret' });

執行腳本,確認是否有成功建立連線並且註冊一個使用者。

1
node src/firebase/auth.js

建立認證模組

新增 src/firebase/app.js 檔,初始化 Firebase 實例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { initializeApp } from 'firebase/app';

const { VITE_FIREBASE_API_KEY, VITE_FIREBASE_AUTH_DOMAIN, VITE_FIREBASE_PROJECT_ID, VITE_FIREBASE_STORAGE_BUCKET, VITE_FIREBASE_MESSAGING_SENDER_ID, VITE_FIREBASE_APP_ID } = import.meta.env;

const firebaseConfig = {
apiKey: VITE_FIREBASE_API_KEY,
authDomain: VITE_FIREBASE_AUTH_DOMAIN,
projectId: VITE_FIREBASE_PROJECT_ID,
storageBucket: VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: VITE_FIREBASE_APP_ID,
};

const app = initializeApp(firebaseConfig);

export default app;

修改 src/firebase/auth.js 檔,封裝並匯出認證的相關方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { createUserWithEmailAndPassword, getAuth, signInWithEmailAndPassword } from 'firebase/auth';
import app from './app';

const auth = getAuth(app);

export const signUp = ({ email, password }) => createUserWithEmailAndPassword(auth, email, password);

export const signIn = ({ email, password }) => signInWithEmailAndPassword(auth, email, password);

export const signOut = () => auth.signOut();

export const onAuthStateChanged = (callback) => auth.onAuthStateChanged(callback);

export const getCurrentUser = () => {
return new Promise((resolve, reject) => {
const unsubscribe = auth.onAuthStateChanged((user) => {
resolve(user);
unsubscribe();
}, (error) => {
reject(error);
});
});
};

建立 src/firebase/index.js 檔,匯出模組。

1
export * as auth from './auth';

提交修改。

1
2
3
git add .
git commit -m "Add firebase auth"
git push

實作頁面

註冊頁面

建立 src/views/SignUp.vue 檔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
<script setup>
import { auth } from '@/firebase';
import router from '@/router';
import { reactive } from 'vue';

const state = reactive({
formData: {
email: '',
password: '',
},
});

const submit = async () => {
try {
await auth.signUp(state.formData);
router.push({ name: 'home' });
} catch (err) {
alert(err);
}
};
</script>

<template>
<div class="d-flex justify-content-center">
<div
class="card"
style="width: 20rem;"
>
<div class="card-header">
<span class="fs-5">Sign Up</span>
</div>
<div class="card-body">
<form
ref="form"
@submit.prevent="submit"
>
<div class="mb-3">
<label
for="email"
class="form-label"
>
Email
</label>
<input
id="email"
v-model="state.formData.email"
type="text"
class="form-control"
required
>
</div>
<div class="mb-3">
<label
for="password"
class="form-label"
>
Password
</label>
<input
id="password"
v-model="state.formData.password"
type="password"
class="form-control"
required
>
</div>
<div class="mb-3">
Already have an account?
<router-link to="/sign-in">
Sign In
</router-link>
</div>
<div class="d-grid">
<button
type="submit"
class="btn btn-primary"
>
Sign Up
</button>
</div>
</form>
</div>
</div>
</div>
</template>

登入頁面

建立 src/views/SignIn.vue 檔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
<script setup>
import { auth } from '@/firebase';
import router from '@/router';
import { reactive } from 'vue';

const state = reactive({
formData: {
email: '',
password: '',
},
});

const submit = async () => {
try {
await auth.signIn(state.formData);
router.push({ name: 'home' });
} catch (err) {
alert(err);
}
};
</script>

<template>
<div class="d-flex justify-content-center">
<div
class="card"
style="width: 20rem;"
>
<div class="card-header">
<span class="fs-5">Sign In</span>
</div>
<div class="card-body">
<form
ref="form"
@submit.prevent="submit"
>
<div class="mb-3">
<label
for="email"
class="form-label"
>
Email
</label>
<input
id="email"
v-model="state.formData.email"
type="text"
class="form-control"
required
>
</div>
<div class="mb-3">
<label
for="password"
class="form-label"
>
Password
</label>
<input
id="password"
v-model="state.formData.password"
type="password"
class="form-control"
required
>
</div>
<div class="mb-3">
Don't have an account? <router-link to="/sign-up">
Sign Up
</router-link>
</div>
<div class="d-grid">
<button
type="submit"
class="btn btn-primary"
>
Sign In
</button>
</div>
</form>
</div>
</div>
</div>
</template>

登出頁面

建立 src/views/SignOut.vue 檔。

1
2
3
4
5
6
7
8
9
10
11
12
13
<script setup>
import { auth } from '@/firebase';
import router from '@/router';

(async () => {
await auth.signOut();
router.push({ name: 'sign-in' });
})();
</script>

<template>
<div />
</template>

首頁

修改 src/views/HomeView.vue 檔,在登入後的首頁顯示使用者的電子信箱。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<script setup>
import { auth } from '@/firebase';
import { reactive } from 'vue';

const state = reactive({
user: null,
});

auth.onAuthStateChanged((user) => {
state.user = user;
});
</script>

<template>
<template v-if="state.user">
<div>
Hi, {{ state.user.email }}
</div>
</template>
</template>

隱藏導覽列

修改 src/components/AppHeader.vue 檔,當使用者還沒有登入的時候,將導覽列隱藏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
<script setup>
import { auth } from '@/firebase';
import * as bootstrap from 'bootstrap';
import { reactive } from 'vue';
import { useRoute } from 'vue-router';

const route = useRoute();

const links = [
{
title: 'Home',
name: 'home',
},
{
title: 'Customers',
name: 'customer-list',
},
{
title: 'About',
name: 'about',
},
{
title: 'Sign Out',
name: 'sign-out',
},
];

const state = reactive({
user: null,
});

auth.onAuthStateChanged((user) => {
state.user = user;
if (!user) {
// 關閉導覽列
bootstrap.Offcanvas.getOrCreateInstance('#offcanvasDarkNavbar').hide();
}
});
</script>

<template>
<nav class="navbar navbar-dark bg-dark fixed-top">
<div class="container-fluid">
<a
class="navbar-brand"
href="/"
>
Simple CMS
</a>
<button
v-if="state.user"
class="navbar-toggler"
type="button"
data-bs-toggle="offcanvas"
data-bs-target="#offcanvasDarkNavbar"
aria-controls="offcanvasDarkNavbar"
aria-label="Toggle navigation"
>
<span class="navbar-toggler-icon" />
</button>
<div
id="offcanvasDarkNavbar"
class="offcanvas offcanvas-end text-bg-dark"
tabindex="-1"
aria-labelledby="offcanvasDarkNavbarLabel"
>
<div class="offcanvas-header">
<h5
id="offcanvasDarkNavbarLabel"
class="offcanvas-title"
>
Simple CMS
</h5>
<button
type="button"
class="btn-close btn-close-white"
data-bs-dismiss="offcanvas"
aria-label="Close"
/>
</div>
<div class="offcanvas-body">
<ul class="navbar-nav justify-content-end flex-grow-1 pe-3">
<template
v-for="(link, i) in links"
:key="i"
>
<li
class="nav-item"
>
<router-link
class="nav-link"
:class="{
'active': link.name === route.name,
}"
:to="{
name: link.name,
}"
>
{{ link.title }}
</router-link>
</li>
</template>
</ul>
</div>
</div>
</div>
</nav>
</template>

提交修改。

1
2
3
git add .
git commit -m "Add auth pages"
git push

建立路由守衛

修改 src/router/index.js 檔,限制特定的路由只能在登入時進入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
import { auth } from '@/firebase';
import HomeView from '@/views/HomeView.vue';
import { createRouter, createWebHashHistory } from 'vue-router';

const router = createRouter({
history: createWebHashHistory(),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
meta: {
requiresAuth: true,
},
},
{
path: '/sign-up',
name: 'sign-up',
component: () => import('@/views/SignUp.vue'),
meta: {
requiresAuth: false,
},
},
{
path: '/sign-in',
name: 'sign-in',
component: () => import('@/views/SignIn.vue'),
meta: {
requiresAuth: false,
},
},
{
path: '/sign-out',
name: 'sign-out',
component: () => import('@/views/SignOut.vue'),
meta: {
requiresAuth: false,
},
},
{
path: '/about',
name: 'about',
component: () => import('@/views/AboutView.vue'),
meta: {
requiresAuth: true,
},
},
{
path: '/customers',
name: 'customer-list',
component: () => import('@/views/CustomerListView.vue'),
meta: {
requiresAuth: true,
},
},
{
path: '/customers/create',
name: 'customer-create',
component: () => import('@/views/CustomerCreateView.vue'),
meta: {
requiresAuth: true,
},
},
{
path: '/customers/:id/edit',
name: 'customer-edit',
component: () => import('@/views/CustomerEditView.vue'),
meta: {
requiresAuth: true,
},
},
],
});

router.beforeEach(async (to, from, next) => {
const currentUser = await auth.getCurrentUser();

if (to.meta.requiresAuth) {
if (!currentUser) {
return next({ name: 'sign-in' });
}
}

next();
});

export default router;

提交修改。

1
2
3
git add .
git commit -m "Add navigation guards"
git push

程式碼