92. FCM 알림 서비스 Source code
1. src/firebase.js
// src/firebase.js
import { initializeApp } from 'firebase/app';
import { getMessaging } from 'firebase/messaging';
import { getAuth } from 'firebase/auth';
import { getFirestore, collection } from "firebase/firestore";
// Your Firebase configuration (from Firebase Console)
const firebaseConfig = {
apiKey: "YOUR_API_KEY",
authDomain: "YOUR_PROJECT_ID.firebaseapp.com",
projectId: "YOUR_PROJECT_ID",
storageBucket: "YOUR_PROJECT_ID.appspot.com",
messagingSenderId: "YOUR_MESSAGING_SENDER_ID",
appId: "YOUR_APP_ID"
};
const app = initializeApp(firebaseConfig);
const messaging = getMessaging(app);
const auth = getAuth(app);
const db = getFirestore(app);
const users_collection = collection(db, "users");
export { db, users_collection, auth, messaging };
2. src/main.js
// src/main.js
import Vue from 'vue'
import App from './App.vue'
import './registerServiceWorker'
import router from './router'
import store from './store'
import vuetify from './plugins/vuetify'
import { auth } from '@/firebase';
import { getMessaging, onMessage } from "firebase/messaging";
import { onAuthStateChanged } from 'firebase/auth';
Vue.config.productionTip = false
new Vue({
router,
store,
vuetify,
render: h => h(App),
created() {
// Set up Firebase auth state change listener
const { dispatch } = this.$store;
dispatch('auth/fetchUsers');
onAuthStateChanged(auth, user => {
// 우리는 우리의 웹앱에 저장된 계정 정보를 사용한다.
// user.uid로 웹앱의 firestore DB에서 계정 정보를 가져온다.
if(user != null) {
dispatch('auth/fetchUserWithUid', {uid: user.uid});
}
});
// Request Notification Permission
Notification.requestPermission().then((permission) => {
if (permission === 'granted') {
console.log('Notification permission granted.');
const messaging = getMessaging();
onMessage(messaging, (payload) => {
console.log('Message received. ', payload);
// Show a notification in the foreground
new Notification(payload.notification.title, {
body: payload.notification.body,
icon: "/icon.png"
})
});
// Handle incoming messages in the foreground
onMessage(messaging, (payload) => {
console.log('Message received: ', payload);
// Extract notification data
const title = payload.notification.title;
const options = {
body: payload.notification.body,
icon: payload.notification.icon || "/img/push-noti-icon.png",
badge: "/img/push-badge-icon.png",
image: "/img/push-image.png",
data: { url: payload.notification.click_action || 'https://velog.io/@inetsos/posts' } // Custom data for the click event
};
// Show notification
if (Notification.permission === 'granted') {
const notification = new Notification(title, options);
// Handle click event on the notification
notification.onclick = (event) => {
event.preventDefault(); // Prevent the browser from focusing the Notification's tab
window.open(notification.data.url, '_blank'); // Open the URL in a new tab
};
}
});
} else {
console.log('Unable to get permission to notify.');
}
});
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/firebase-messaging-sw.js')
.then((registration) => {
console.log('Service Worker registered with scope:', registration.scope);
}).catch((err) => {
console.error('Service Worker registration failed:', err);
});
}
}
}).$mount('#app')
3. src/router/index.js
// src/router/index.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import HomeView from '../views/HomeView.vue'
import LoginView from '@/views/LoginView.vue';
import RegisterView from '@/views/RegisterView.vue';
import ProfileView from '@/views/ProfileView.vue';
import SubscribeView from '@/views/SubscribeView.vue'
import SendNotificationView from '@/views/SendNotificationView.vue'
import store from '../store';
Vue.use(VueRouter)
const routes = [
{
path: '/',
name: 'home',
component: HomeView
},
{
path: '/login',
name: 'LoginView',
component: LoginView
},
{
path: '/register',
name: 'RegisterView',
component: RegisterView
},
{
path: '/profile',
name: 'ProfileView',
component: ProfileView,
meta: { requiresAuth: true }
},
{
path: '/subscribe',
name: 'subscribe',
component: SubscribeView
},
{
path: '/notification',
name: 'notification',
component: SendNotificationView
},
{
path: '/about',
name: 'about',
// route level code-splitting
// this generates a separate chunk (about.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import(/* webpackChunkName: "about" */ '../views/AboutView.vue')
}
]
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes
})
router.beforeEach((to, from, next) => {
const requiresAuth = to.matched.some(record => record.meta.requiresAuth);
const user = store.state.auth.user;
if (requiresAuth && !user) {
next('/login');
} else {
next();
}
});
export default router
4. src/store/modules/auth.js
// src/store/modules/auth.js
import { db, users_collection, auth } from '@/firebase';
import router from '@/router'; // Vue Router import
import { createUserWithEmailAndPassword, signInWithEmailAndPassword, signInWithPopup, GoogleAuthProvider } from 'firebase/auth';
import { doc, addDoc, getDocs, query, where, updateDoc, arrayUnion } from "firebase/firestore";
const state = {
user: null,
users: [],
isLoading: false,
error: null
};
const mutations = {
setUser(state, user) {
state.user = user;
},
setUsers(state, users) {
state.users = users;
state.isLoading = false;
},
setError(state, error) {
state.error = error;
state.isLoading = false;
},
setLoading(state, isLoading) {
state.isLoading = isLoading;
}
}
const actions = {
async fetchUsers({ commit }) {
commit('setLoading', true);
try {
const users = [];
const querySnapshot = await getDocs(users_collection);
querySnapshot.forEach((doc) => {
// doc.data() is never undefined for query doc snapshots
users.push({ id: doc.id, ...doc.data() });
});
commit('setUsers', users);
} catch (error) {
commit('setError', error.message);
}
},
async addUser({ dispatch, commit }, user) {
try {
// Add a new document with a generated id.
const newUser = await addDoc(users_collection, {
email: user.email,
name: user.name,
uids: user.uids
});
// 로그인 설정
user.id = newUser.id;
console.log('addUser :', user);
commit('setUser', user);
dispatch('fetchUsers');
} catch (error) {
console.error('Error adding user:', error);
}
},
async login({ commit, dispatch }, { email, password }) {
try {
const { user } = await signInWithEmailAndPassword(auth, email, password);;
// 웹앱의 계정 정보를 가져와 로그인 설정을 한다.
dispatch('fetchUserWithUid', {uid: user.uid});
router.push("/"); // home으로
} catch (error) {
commit('setError', error.message);
}
},
async fetchUserWithUid({ commit }, {uid}) {
try {
const users = [];
const userRef = query(users_collection, where('uids', 'array-contains', uid));
const querySnapshot = await getDocs(userRef);
querySnapshot.forEach((doc) => {
// doc.data() is never undefined for query doc snapshots
users.push({ id: doc.id, ...doc.data() });
});
// 로그인 설정을 한다.
commit('setUser', users[0]);
} catch (error) {
console.error('Error fetching user:', error);
}
},
async register({ commit, dispatch }, { email, password, name }) {
try {
const { user } = await createUserWithEmailAndPassword(auth, email, password, name);
// 웹앱에 계정을 만든다.
const newUser = {
email: email,
name: name,
uids: [user.uid]
};
// newUser 객체가 추가됨
// dispatch('addUser', { user: newUser });
// newUser 계정 정보가 추가됨
dispatch('addUser', newUser);
router.push("/"); // home으로
} catch (error) {
console.log(error);
commit('setError', error.message);
}
},
async addUidToUser({ state }, newUid) {
//console.log('user.id:', state.user.id);
if (state.user) {
try {
const userDoc = doc(db, "users", state.user.id);
updateDoc(userDoc, {
uids: arrayUnion(newUid)
});
// Update the local state if neededa
state.user.uids = [...(state.user.uids || []), newUid];
} catch (error) {
console.error('Error adding UID to user:', error);
}
}
},
async addGoogleUid({ commit, dispatch, getters }) {
try {
const provider = new GoogleAuthProvider();
const { user } = await signInWithPopup(auth, provider);
try {
// 이미 연동된 회원의 경우 알림 메시지 출력한다.
const myUser = getters.getUserByUid(user.uid);
if (myUser) {
// 이미 연동되어 있다.
commit('setError','이미 연동되어 있습니다.');
} else {
// 구글 연동을 진행한다.
dispatch('addUidToUser', user.uid);
}
} catch (error) {
commit('setError','Error adding google uid');
}
} catch (error) {
commit('setError', error.message);
}
},
async googleLogin({ commit, getters }) {
try {
// 구글 계정에 로그인
const provider = new GoogleAuthProvider();
const { user } = await signInWithPopup(auth, provider)
//const { user } = await signInWithPopup(auth); //, googleProvider);
try {
// 구글계정의 uid로 웹앱 계정의 정보를 가져옴.
// 웹앱 계정은 사이트에 접속할 때 전체 회원 정보를 로드하였으므로
// 이미 로드된 회원 리스트에서 구글 계정 uid를 가진 myUser를 가져온다.
const myUser = getters.getUserByUid(user.uid);
if (myUser) {
commit('setUser', myUser);
router.push("/"); // home으로
} else {
console.log('등록된 회원이 아닙니다.');
// 이경우 회원 가입 페이지로 이동 필요
}
} catch (error) {
console.error('Error adding user:', error);
}
} catch (error) {
commit('setError', error.message);
}
},
async logout({ commit }) {
await auth.signOut();
commit('setUser', null);
},
setError({ commit }, err_message) {
commit('setError', err_message);
},
resetError({ commit }) {
commit('setError', null);
},
setUser({ commit }, user) {
commit('setUser', user);
}
}
const getters = {
user: state => state.user,
isAuthenticated: state => !!state.user,
users: state => state.users,
isLoading: state => state.isLoading,
error: state => state.error,
getUserByUid: (state) => (uid) => {
return state.users.find(user => user.uids && user.uids.includes(uid));
}
};
export default {
namespaced: true,
state,
mutations,
actions,
getters
};
5. src/store/index.js
// src/store/index.js
import Vue from 'vue';
import Vuex from 'vuex';
import auth from './modules/auth';
import posts from './modules/posts';
Vue.use(Vuex);
export default new Vuex.Store({
modules: {
auth
}
});
6. src/views/AboutView.vue
<!-- src/views/AboutView.vue -->
<template>
<div class="about">
<br>
<h3>my-fcm-3는 firebase 생태계의 푸시 알림 서비스를 개발하고 있습니다.</h3>
</div>
</template>
7. src/views/HomeView.vue
<!-- src/views/HomeView.vue -->
<template>
<v-container>
<v-card>
<v-card-title>my-fcm-3 Home</v-card-title>
</v-card>
</v-container>
</template>
<script>
export default {
name: 'Home'
}
</script>
8. src/views/Login.vue
<!-- src/views/Login.vue -->
<template>
<v-container>
<v-row>
<v-col cols="12" class="text-center my-5">
<h3>로그인</h3>
</v-col>
</v-row>
<v-row>
<v-col class="text-center" cols="10" offset="1" sm="8" offset-sm="2">
<v-form @submit.prevent="loginUser">
<v-text-field v-model="email" label="이메일" type="email" required></v-text-field>
<v-text-field v-model="password" label="비밀번호" type="password" required></v-text-field>
<v-btn type="submit" color="primary">Login</v-btn>
<v-alert v-if="error" type="error" dismissible>{{ error }}</v-alert>
</v-form>
</v-col>
</v-row>
<v-row>
<v-col class="text-center" cols="10" offset="1" sm="8" offset-sm="2">
<v-btn color="red" @click="googleLogin" dark>
<v-icon left>mdi-google</v-icon>
Sign in with Google
</v-btn>
</v-col>
</v-row>
</v-container>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
export default {
name: 'Login',
data() {
return {
email: '',
password: ''
};
},
computed: {
...mapGetters('auth',['error'])
},
methods: {
...mapActions('auth', ['login', 'googleLogin']),
async loginUser() {
await this.login({ email: this.email, password: this.password });
}
}
};
</script>
9. src/views/ProfileView.vue
<!-- src/views/ProfileView.vue -->
<template>
<v-container>
<v-card v-if="user">
<v-card-title>User Profile</v-card-title>
<v-card-text>
<p><strong>이메일:</strong> {{ user.email }}</p>
<p><strong>이름:</strong> {{ user.name }}</p>
<p><strong>id:</strong> {{ user.id }}</p>
</v-card-text>
<v-card-text>
<v-btn color="red" @click="addGoogleAccount" dark>
<v-icon left>mdi-google</v-icon>
Google 계정 연동
</v-btn>
<v-alert v-if="error" type="error" dismissible @input="resetErrorMsg" class="my-alert">{{ error }}</v-alert>
</v-card-text>
</v-card>
<v-alert v-else type="info">No user is logged in.</v-alert>
</v-container>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
export default {
computed: {
...mapGetters('auth',['user', 'error'])
},
methods: {
...mapActions('auth',['addGoogleUid', 'resetError']),
async addGoogleAccount() {
await this.addGoogleUid();
},
resetErrorMsg() {
this.resetError();
}
}
};
</script>
<style scoped>
.my-alert {
margin: 20px 0;
}
</style>
10. src/views/RegisterView.vue
<!-- src/views/RegisterView.vue -->
<template>
<v-container>
<v-row>
<v-col cols="12" class="text-center my-5">
<h3>계정 만들기</h3>
</v-col>
</v-row>
<v-row>
<v-col class="text-center" cols="10" offset="1" sm="8" offset-sm="2">
<v-form @submit.prevent="userRegister">
<v-text-field v-model="email" label="이메일" type="email" required></v-text-field>
<v-text-field v-model="name" label="이름" type="text" required></v-text-field>
<v-text-field v-model="password" label="비밀번호" type="password" required></v-text-field>
<v-text-field v-model="confirmPassword" name="confirmPassword" label="비밀번호 확인" type="password" required :rules="[comparePassword]"></v-text-field>
<v-progress-circular v-if="isLoading" indeterminate :width="7" :size="70" color="grey lighten-1"></v-progress-circular>
<v-btn type="submit" color="primary">Register</v-btn>
<v-alert v-if="error" type="error" dismissible>{{ error }}</v-alert>
</v-form>
</v-col>
</v-row>
</v-container>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
export default {
data() {
return {
email: '',
name: '',
password: '',
confirmPassword: ''
};
},
computed: {
...mapGetters('auth', ['error', 'isLoading']),
comparePassword() {
if(this.password == this.confirmPassword) return true;
else return "비밀번호가 일치하지 않습니다.";
},
},
methods: {
...mapActions('auth', ['register']),
async userRegister() {
await this.register({ email: this.email, password: this.password, name: this.name });
}
}
};
</script>
11. src/views/SendNotificationView.vue
<!-- src/views/SendNotificationView.vue -->
<template>
<v-container>
<v-row wrap>
<v-col class="mb-4">
<h2 class="display-1 font-weight-bold mb-3">
Welcome to my-fcm-3
</h2>
</v-col>
<v-col cols="12" class="text-center">
<h4 class="display-1">알림 보내기</h4>
</v-col>
<v-col class="my-3" offset="1" cols="10">
<v-card color="blue-grey lighten-1" dark>
<v-container class="my-3">
<v-row>
<v-col cols="12">
<v-text-field autofocus name="title" label="제목" type="text" v-model="notiTitle" color="white"></v-text-field>
</v-col>
<v-col cols="12">
<v-textarea rows="3" name="message" label="내용" type="text" v-model="notiMsg" color="white"></v-textarea>
</v-col>
</v-row>
</v-container>
<v-card-text>
<v-btn block large color="primary" dark @click="sendNotification">
<v-icon left>mdi-message</v-icon>Send Notification
</v-btn>
</v-card-text>
</v-card>
<v-alert v-if="error" type="error" dismissible @input="resetErrorMsg" class="my-alert">{{ error }}</v-alert>
</v-col>
</v-row>
</v-container>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
export default {
data() {
return {
notiTitle: '',
notiMsg: ''
}
},
computed: {
...mapGetters('auth',['error'])
},
methods: {
...mapActions('auth',['setError', 'resetError']),
resetErrorMsg() {
this.resetError();
},
sendNotification() {
const user = this.$store.state.auth.user;
if(user) {
const userId = user.id;
this.triggerNotification(userId);
}
},
async triggerNotification(userId) {
if(this.notiTitle == '' || this.notiMsg == '') {
this.setError("제목과 내용을 입력하세요.");
return;
}
try {
const response = await fetch('https://sendpushnotification-your-region-your-project-id.net', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userId: userId,
title: this.notiTitle, //"알림 테스트",
body: this.notiMsg, // "알림을 보냅니다. 받아 주세요....."
}),
});
if (response.ok) {
console.log('Notification sent successfully');
} else {
console.error('Failed to send notification');
}
} catch (error) {
console.error('Error:', error);
}
}
}
}
</script>
<style scoped>
.my-alert {
margin: 20px 0;
}
</style>
12. src/views/SubscribeView.vue
<!--src/views/SubscribeView.vue-->
<template>
<v-container>
<v-row>
<v-col class="mb-4">
<h1 class="display-2 font-weight-bold mb-3">
Welcome to my-fcm-3
</h1>
</v-col>
<v-col cols="12">
Firebase Cloud Messaging with Vue and Firebase<br>
<v-btn color="primary" @click="requestNotificationPermission">Enable Notifications</v-btn>
</v-col>
</v-row>
</v-container>
</template>
<script>
import { db, messaging } from '@/firebase'
import { getToken } from 'firebase/messaging';
import { doc, setDoc } from 'firebase/firestore';
export default {
methods: {
requestNotificationPermission() {
if ("Notification" in window) {
Notification.requestPermission().then((permission) => {
if (permission === "granted") {
console.log("Notification permission granted.");
getToken(messaging, { vapidKey: “your-web-push-certificate-key-pair" })
.then((currentToken) => {
if (currentToken) {
console.log("FCM Token:", currentToken);
// Send the token to your server and update the UI if necessary
const user = this.$store.state.auth.user;
if(user) {
const userId = user.id;
this.saveTokenToFirestore(userId, currentToken);
}
} else {
console.log("No registration token available. Request permission to generate one.");
}
})
.catch((err) => {
console.log("An error occurred while retrieving token. ", err);
});
} else {
console.log("Notification permission denied.");
}
});
} else {
console.log("This browser does not support notifications.");
}
},
saveTokenToFirestore(userId, token) {
try {
// Create a reference to the document in the 'fcmTokens' collection with the user's ID as the document ID
const tokenRef = doc(db, 'fcmTokens', userId);
// Set the document with the token data
setDoc(tokenRef, {
token: token,
createdAt: new Date()
});
console.log(`Token for user ${userId} saved to Firestore.`);
} catch (error) {
console.error('Error saving token to Firestore:', error);
}
},
}
};
</script>
13. src/App.vue
<!--src/App.vue -->
<template>
<v-app>
<v-navigation-drawer app>
<v-list>
<v-list-item link>
<router-link to="/">Home</router-link>
</v-list-item>
<v-list-item link v-if="!user">
<router-link to="/login" >로그인</router-link>
</v-list-item>
<v-list-item link v-if="!user">
<router-link to="/register">계정 만들기</router-link>
</v-list-item>
<v-list-item link v-if="user">
<router-link to="/profile">계정 정보</router-link>
</v-list-item>
<v-list-item link>
<router-link to="/subscribe">구독</router-link>
</v-list-item>
<v-list-item link>
<router-link to="/notification">알림</router-link>
</v-list-item>
<v-list-item link>
<router-link to="/about">About</router-link>
</v-list-item>
</v-list>
</v-navigation-drawer>
<v-app-bar app color="primary" dark >
<v-toolbar-title>my-fcm-3</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn icon @click="doLogout" v-if="user">
<v-icon>mdi-logout</v-icon>
</v-btn>
</v-app-bar>
<v-main>
<router-view/>
</v-main>
</v-app>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
export default {
name: 'App',
computed: {
...mapGetters('auth', ['user'])
},
methods: {
...mapActions('auth', ['logout']),
doLogout() {
this.logout()
}
}
};
</script>