토이 프로젝트 - Vue, Firebase로 서버리스 PWA 개발

11. Vue(with Vuetify)와 Firebase로 서버리스 PWA myBlog 개발 - 계정 설정

그랜파 개발자 2025. 2. 11. 16:05

계정 설정

Firebase Authentication은 사용자 계정 생성만 처리합니다.
기본 정보 외에 사용자 관련 추가 정보가 필요하고 이것을 저장하려면 Firestore와 함께 사용해야 합니다.

 

사용자 관련 추가 데이터는 계정 생성을 하면서 계정 설정 정보도 함께 생성하여,
Firestore의 profiles 컬렉션에 저장합니다.

 

계정 설정 항목은 다음과 같습니다.

  • userId : 로그인 Id입니다. 계정 만들기에 등록된 정보입니다.
  • email : 로그인할 때 이메일입니다. 이미 계정 생성에서 등록된 정보이므로 계정 설정에서는 입력하지 않습니다.
  • name : 사용자의 이름 또는 별명입니다.
  • blogName : 사용자의 블로그 이름입니다.
  • aboutMe : 사용자의 현재 상태 정보입니다.
  • createdAt : 계정 정보 생성일입니다. 계정 설정 정보를 저장할 때 시스템 시간을 자동으로 얻어 저장합니다.
  • uids[] : 여러 OAuth를 이용한 로그인을 위한 것입니다.

계정 설정 정보 중에 생성을 할 때 정해지는 정보는 userId, email, createAt, uids 입니다.
name, blogName, aboutMe는 계정 생성 상황에서 알 수 없으므로 계정 생성할 때 공란으로 저장되었습니다.

계정 설정에서 사용자 계정 생성할 때 공란으로 저장된 계정 설정 정보 name, blogName, aboutMe를 입력합니다.
이미 등록된 계정 설정 정보를 수정하는 것입니다.

 

계정 설정에서 비밀번호도 변경할 수 있습니다.
비밀번호 리셋은 비밀번호를 잊었을 경우에 처리하기 위한 기능으로 로그인 페이지에 기능을 두었다면,
비밀번호 변경은 로그인 후에 비밀번호를 변경하므로 계정 설정에 기능을 둡니다.

 

 

계정 설정 로드

계정 생성할 때 기본적인 계정 설정 정보도 함께 생성하여 Firestore에 저장하고,
이것을 로드하여 상태 변수 profile에 저장합니다.
앱이 실행될 때 전체 사용자 계정 설정 정보를 로드할 때 로그인 한 사용자의 계정 설정 정보도 로드 합니다.
또한 로그인을 할 때에도 로그인한 user에 대한 계정 설정 정보를 로드하여
상태 변수 profile에 저장합니다.

 
 
  // -- 계정 설정 정보 로드
  async fetchProfile({ commit }, profileId) {
    try {
      const profileRef = doc(db, "profiles", profileId);
      const profileSnap = await getDoc(profileRef);   // 문서 가져오기
      if (profileSnap.exists()) {
        //console.log('profile: ', profileSnap.data());
        // 계정 설정 정보로 로그인한 사용자 저장
        commit("setProfile", {id:profileId, ...profileSnap.data()}); 
      } else {
        console.log("fetchProfile : " + userId + " 계정 설정 정보가 없습니다.");
      }
    } catch (error) {
      alert("Failed to fetch user. : " + error.message);
    }
  },
 

 

계정 설정 정보는 계정 생성할 때 이미 생성하여 저장한 것으로
계정 설정 페이지에서는 등록된 계정 정보의 수정하는 것입니다.

계정 설정 정보는 로그인할 때 이미 로드하였으므로
계정 설정 페이지가 열릴 때에는 store의 상태 변수 profile에 로드되어 있는 정보를 가져옵니다.

각 회원 정보 항목을 입력 또는 수정한 후 저장을 하면 profiles 컬렉션에 문서로 저장이 됩니다.
계정 설정 정보를 저장한 후 store의 상태 변수 profile에 회원 정보를 새로 로드합니다.

 
  // 기존의 계정 설정 정보를 수정 한다.
  async updateProfile({ dispatch, commit, state }, profile) {
    try {
      commit('setLoading', true);

      const userDoc = doc(db, "profiles", state.profile.id);
      await updateDoc(userDoc, profile);

      // 전체 계정 설정 정보를 다시 로드한다.
      // 개별 profile 로드도 포함되어 있다.
      dispatch('fetchProfiles');

      commit('setLoading', false);

      alert("계정 정보를 수정하였습니다.");
    } catch (error) {
      alert("계정 정보 수정 실패: " + error.message);
    }
  },

 

비밀번호 변경

 

로그인을 한 후 비밀번호를 변경할 때는 로그인한 사용자가 비밀번호를 직접 변경합니다.
비밀번호 변경 버튼을 누르면 비밀번호 변경창이 나타납니다.
현재 비밀번호와 새비밀번호를 입력함 후 비밀번호 변경을 누르면 비밀번호가 변경됩니다.
비밀번호 변경의 경우 민감한 작업이므로 EmailAuthProvider.credential(email, password)로 재인증이 되면 새로 입력한 비밀번호로 변경하도록 합니다.

 
async changePassword({}, { oldPassword, newPassword }) {  

    const credential = EmailAuthProvider.credential(
      auth.currentUser.email,
      oldPassword
    );

    reauthenticateWithCredential(auth.currentUser, credential)
    .then(() => {
      updatePassword(auth.currentUser, newPassword)
      .then(() => {
        alert("비밀번호가 성공적으로 변경되었습니다.");
      }).catch((error) => {
        alert("비밀번호 변경 실패 : " + error.message);
      });
    })
    .catch((error) => {
      alert("재인증 실패 : " + error.message);
    });
  },

ProfileView.vue

<!-- src/views/ProfileView.vue -->
<template>
  <v-container>
    <v-row>
      <v-col>
        <v-card class="pa-2">
          <v-card-title>
            <span class="text-h6">계정 설정</span>
          </v-card-title>

          <v-card-text>
            <v-form ref="form" v-model="valid" lazy-validation>

              <!-- Email Field (Non-Editable) -->
              <v-text-field label="이메일" v-model="editableUserInfo.email" disabled readonly></v-text-field>
              <!-- Name Field -->
              <v-text-field label="이름" v-model="editableUserInfo.name" :rules="[rules.required]"></v-text-field>
              <!-- blogName Field -->
              <v-text-field label="블로그 이름" v-model="editableUserInfo.blogName" :rules="[rules.required]"></v-text-field>
              <!-- aboutMe Field -->
              <v-text-field label="상태" v-model="editableUserInfo.aboutMe" :rules="[rules.required]"></v-text-field>
            </v-form>
          </v-card-text>

          <v-card-actions class="mt-n8">
            <v-spacer></v-spacer>
            <v-btn color="primary" @click="saveChanges" :disabled="!valid">저장</v-btn>
            <v-btn color="grey" @click="cancelEdit">취소</v-btn>
          </v-card-actions>

          <v-card-actions class="mt-2">
            <v-btn @click="showForm = !showForm">
              <v-icon left>mdi-lock</v-icon>비밀번호 변경
            </v-btn>
            <v-spacer></v-spacer>
            <v-btn color="red" @click="doAddGoogleAccount" dark>  
              <v-icon left>mdi-google</v-icon>Google 연동
            </v-btn>            
          </v-card-actions>

        </v-card>
      </v-col>
    </v-row>

    <v-row v-if="showForm">
      <v-col>
        <v-card class="pa-2">
          <v-card-title style="font-size:1em" class="mt-n2">
            비밀번호 변경
          </v-card-title>

          <v-card-text  class="mt-n4">
            <v-form ref="form" v-model="valid" lazy-validation> 
              <v-text-field v-model="oldPassword" :rules="passwordRules" label="현재 비밀번호" prepend-icon="mdi-lock" type="password" required></v-text-field>
              <v-text-field v-model="newPassword" :rules="passwordRules" label="새 비밀번호" prepend-icon="mdi-lock" type="password" required></v-text-field>
            </v-form>
          </v-card-text>

          <v-card-actions class="mt-n8">
            <v-spacer></v-spacer>
            <v-btn @click="doChangePassword" :disabled="!valid">
              <v-icon left>mdi-lock</v-icon>비밀번호 변경
            </v-btn>       
          </v-card-actions>

        </v-card>       
      </v-col>
    </v-row>

    <div class="text-center">
      <v-progress-circular v-if="loading" indeterminate></v-progress-circular>
    </div>
  </v-container>
</template>

<script>
import { mapActions, mapState } from "vuex";

export default {
  data() {
    return {
      editableUserInfo: {
        email: "",
        name: "",
        blogName: "",
        aboutMe: ""
      },
      showForm: false,
      oldPassword:"",
      newPassword:"",
      valid: false,
      validPassword: false,
      rules: {
        required: (value) => !!value || "Required.",
        numeric: (value) => !isNaN(parseInt(value)) || "Must be a number.",
      },
      passwordRules: [
        (v) => !!v || 'Password is required',
        (v) => (!!v && v.length >= 6) || 'Password must be at least 6 characters',
      ],
    };
  },
  computed: {
    ...mapState('auth', ["user", "profile", "loading"]),
    getUserInfo() {
      return this.profile;
    },
  },
  watch: {
    getUserInfo: {
      handler(newValue) { 
        this.editableUserInfo = { ...newValue }; // Clone the userInfo to editableUserInfo
      },
      immediate: true,
    },
  },
  methods: {
    ...mapActions('auth', ["registerProfile","updateProfile", "changePassword", "addGoogleAccount"]),
    saveChanges() {
      this.updateProfile(this.editableUserInfo);
    },
    cancelEdit() {
      this.editableUserInfo = { ...this.profile }; // Reset form to original data
    },
    async doAddGoogleAccount() {
      await this.addGoogleAccount();
    },
    async doChangePassword() {
      if (!this.oldPassword || !this.newPassword ) {
        alert("비밀번호를 입력하세요.");
        return;
      }

      this.changePassword({
        oldPassword: this.oldPassword, 
        newPassword: this.newPassword
      });
    }
  }
};
</script>