iT邦幫忙

2023 iThome 鐵人賽

DAY 1
0
Modern Web

一些讓你看來很強的全端- trcp 伴讀系列 第 28

Day-028. 一些讓你看來很強的全端 TRPC 伴讀 -Token authorization ( page )

  • 分享至 

  • xImage
  •  

昨天介紹如何使用 cloundinary 今天繼續介紹 page 部分。

Register page

register page 需要注意的是每當 user 上傳圖片時,需要即時顯示照片,然後當 form submit 時打 register api 並同時把圖片上傳到 cloundinary,然後根據 uploadImageToCloudinary return profile url 然後在 insertuserphoto 中。

uploadImageToCloudinary : 圖片上傳到 cloundinary ,並呼叫 cloundinaryupload api ,然後 apireturninfointerface UploadImageApiResponse

interface UploadImageApiResponse {
  asset_id: string;
  public_id: string;
  version: number;
  version_id: string;
  signature: string;
  width: number;
  height: number;
  format: string;
  resource_type: string;
  created_at: string;
  tags: any[];
  bytes: number;
  type: string;
  etag: string;
  placeholder: boolean;
  url: string;
  secure_url: string;
  folder: string;
  access_mode: string;
  existing: boolean;
}



handleRegisterProfile : 先呼叫 uploadImageToCloudinary 等他 return profile sourceURLcall register api

api.auth.registerUser.useMutation : 等到 onSuccess 就導頁到 login page

src/pages/register.tsx

import { zodResolver } from '@hookform/resolvers/zod'
import Link from 'next/link'
import { useRouter } from 'next/router'
import React, { ChangeEvent, useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'react-toastify'
import { FileUpload } from '~/client/components/FileUpload'
import { FormInput } from '~/client/components/FormInput'
import { LoadingButton } from '~/client/components/LoadingButton'
import { useStore } from '~/client/store'
import { CreateUserFormSchema, createUserFormSchema } from '~/server/schema/user.schema'
import { api } from '~/utils/trpc'

function RegisterPage() {
  const router = useRouter()
  const { mutateAsync: registerUser, isLoading } = api.auth.registerUser.useMutation({
    onSuccess: () => {
      toast.success('success register user')
      router.push('/login')
    },
    onError: (e) => {
      toast.error(e.message)
    }
  })
  const [avatar, setAvatar] = useState<string>()
  const { setUPloadImage } = useStore()
  const { register, handleSubmit, reset, formState: { errors }, getValues, watch } = useForm<CreateUserFormSchema>({
    resolver: zodResolver(createUserFormSchema),
    mode: 'onChange'
  })
  const onSubmitHandler = async (data: CreateUserFormSchema) => {
    const { photo } = data
    const url = await uploadImageToCloudinary(photo)
    registerUser({
      name: data.name,
      password: data.password,
      email: data.email,
      photo: url || ''
    })
  }

  const uploadImageToCloudinary = async (image: ((string | false | File) & (string | false | File | undefined)) | null) => {
    if (!image || typeof image === 'string') return
    let formdata = new FormData()
    formdata.append("file", image);
    formdata.append("upload_preset", process.env.NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET);
    formdata.append("public_id", image.name);
    formdata.append("api_key", process.env.NEXT_PUBLIC_CLOUDINARY_APIKEY);
    formdata.append("tags", 'profile');
    formdata.append("folder", getValues('name'));
    setUPloadImage(true)
    const result: UploadImageApiResponse = await fetch(`https://api.cloudinary.com/v1_1/${process.env.NEXT_PUBLIC_CLOUDINARY_NAME}/image/upload`, {
      method: 'POST',
      body: formdata,
      redirect: 'follow'
    })
      .then(response => response.json())
      .catch(error => console.log('error', error))
      .finally(() => {
        setUPloadImage(false)
      })
    return result.secure_url

  }

  const handleRegisterProfile = async (e: ChangeEvent<HTMLInputElement>) => {
    await register('photo').onChange(e)
    const file = e.target.files?.[0]
    if (!file) return
    const reader = new FileReader()
    reader.readAsDataURL(file)
    reader.onload = () => {
      if (!reader.result) return
      setAvatar(reader.result?.toString())
    }
  }
  return (
    <section className="py-8 bg-ct-blue-600 min-h-screen grid place-items-center">
      <div className="w-full">
        <h1 className="text-4xl xl:text-6xl text-center font-[600] text-ct-yellow-600 mb-4">
          Welcome
        </h1>
        <h2 className="text-lg text-center mb-4 text-ct-dark-200">
          Sign Up To Get Started!
        </h2>
        <form
          onSubmit={handleSubmit(onSubmitHandler)}
          className="max-w-md w-full mx-auto overflow-hidden shadow-lg bg-ct-dark-200 rounded-2xl p-8 space-y-5"
        >
          <FormInput
            register={register}
            error={errors.name}
            label="Full Name"
            name="name"
          />
          <FormInput
            register={register}
            error={errors.email}
            label='email'
            name='email'
          />
          <FileUpload
            register={register}
            name='photo'
            error={errors.photo}
            label='profile'
            onChange={handleRegisterProfile}
          />
          {(avatar && !errors.photo) && (
            <div className='w-[100px] h-[100px] rounded-xl overflow-hidden relative'>
              <img src={avatar} alt="" className='object-cover w-full h-full ' />
            </div>
          )}

          <FormInput
            register={register}
            error={errors.password}
            label='password'
            name='password'
          />
          <FormInput
            register={register}
            error={errors.passwordConfirm}
            label='confirm password'
            name='passwordConfirm'
          />


          <span className="block">
            Already have an account?{" "}
            <Link href="/login" className="text-ct-blue-600">
              Login Here
            </Link>
          </span>
          <LoadingButton loading={isLoading} textColor="text-ct-blue-600">
            Sign Up
          </LoadingButton>
        </form>
      </div>
    </section>
  )
}

export default RegisterPage

完整 demo

https://ithelp.ithome.com.tw/upload/images/20231012/20145677lzD1iADzZj.png
註冊成功導入 login page

https://ithelp.ithome.com.tw/upload/images/20231012/20145677Q681IX4vpl.png

Login page

src/pages/login.tsx

api.auth.loginUser : onSuccessset access_tokenuseStore 中,然後導到 profile page

import Link from 'next/link'
import React from 'react'
import { FormInput } from '~/client/components/FormInput'
import { LoadingButton } from '~/client/components/LoadingButton'
import { useForm } from 'react-hook-form'
import { loginUserSchema, LoginUserSchema } from '~/server/schema/user.schema'
import { zodResolver } from '@hookform/resolvers/zod'
import { api } from '~/utils/trpc'
import { toast } from 'react-toastify'
import { useRouter } from 'next/router'
import { useStore } from '~/client/store'

function LoginPage() {
  const { setAccessToken, access_token } = useStore()
  const router = useRouter()
  const { register, handleSubmit, formState: { errors } } = useForm<LoginUserSchema>({
    resolver: zodResolver(loginUserSchema),
    mode: 'onChange'
  })
  const { mutateAsync: userLogin, isLoading } = api.auth.loginUser.useMutation({
    onSuccess: (data) => {
      toast.success('success login ')
      setAccessToken(data.access_token)
      router.push('/profile')
    },
    onError: (e) => {
      toast.error(e.message)
    }
  })
  const onSubmitHandler = async (data: LoginUserSchema) => {
    userLogin(data)
  }
  return (
    <section className="py-8 bg-ct-blue-600 min-h-screen grid place-items-center">
      <div className="w-full">
        <h1 className="text-4xl xl:text-6xl text-center font-[600] text-ct-yellow-600 mb-4">
          Welcome Back
        </h1>
        <h2 className="text-lg text-center mb-4 text-ct-dark-200">
          Login to have access
        </h2>
        <form
          onSubmit={handleSubmit(onSubmitHandler)}
          className="max-w-md w-full mx-auto overflow-hidden shadow-lg bg-ct-dark-200 rounded-2xl p-8 space-y-5"
        >
          <FormInput
            register={register}
            error={errors.email}
            label="email"
            name="email"
          />
          <FormInput
            register={register}
            error={errors.password}
            label="password"
            name="password"
          />
          <LoadingButton loading={isLoading} textColor="text-ct-blue-600">
            Login
          </LoadingButton>
          <span className="block">
            Need an account?{" "}
            <Link href="/register" className="text-ct-blue-600">
              Sign Up Here
            </Link>
          </span>

        </form>
      </div>
    </section>
  )
}

export default LoginPage

登入畫面

https://ithelp.ithome.com.tw/upload/images/20231012/201456771FDaraXHI5.png

Profile page

src/pages/profile.tsx

getMe : 每當進入 Profile page 時候都會先打 getMe 然後渲染 user info
setImage : 只要 user 成功 upload image ,同步更新 getMequery dta

備註 : 在 getMe api 中用了refetchOnWindowFocusrefetchOnMount,其目的是不希望 user 每次切換頁面都重新打 api ,主要是優化 UX 部分,減少 loading indictor

import React, { ChangeEvent, useEffect, useMemo, useState } from 'react'
import { api } from '~/utils/trpc'
import { useStore } from '~/client/store';
import { uploadFormSchema } from '~/server/schema/user.schema';
import { toast } from 'react-toastify';
function ProfilePage() {
  const apiContext = api.useContext()
  const { mutateAsync: setProfileImageUrl } = api.user.setImage.useMutation({
    onSuccess: () => {
      apiContext.user.getMe.invalidate()
      toast.success('success update user profile')
    }
  })

  const { data } = api.user.getMe.useQuery(undefined, {
    refetchOnWindowFocus: false,
    refetchOnMount: false,
    trpc: {
      context: {
        skipBatch: true
      }
    }
  })
  const user = useMemo(() => data?.data.user, [data])
  const { setPageLoading, setAccessToken, access_token } = useStore()
  const uploadImageToCloudinary = async (e: ChangeEvent<HTMLInputElement>) => {
    const image = e.target.files?.[0]
    if (!image) return
    const validateResult = uploadFormSchema.safeParse({ photo: e.target.files })
    if (!validateResult.success) return
    let formdata = new FormData()
    formdata.append("file", image);
    formdata.append("upload_preset", process.env.NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET);
    formdata.append("public_id", image.name);
    formdata.append("api_key", process.env.NEXT_PUBLIC_CLOUDINARY_APIKEY);
    formdata.append("tags", 'profile');
    formdata.append("folder", user?.name as string);
    setPageLoading(true)
    const result: UploadImageApiResponse = await fetch(`https://api.cloudinary.com/v1_1/${process.env.NEXT_PUBLIC_CLOUDINARY_NAME}/image/upload`, {
      method: 'POST',
      body: formdata,
      redirect: 'follow',
      headers: { "Content-Type": "multipart/form-data" }
    })
      .then(response => response.json())
      .catch(error => console.log('error', error))
      .finally(() => {
        setPageLoading(false)
      })
    setProfileImageUrl({ url: result.secure_url ?? 'default.png' })
  }

  return (
    <>
      <section className="bg-ct-blue-600 min-h-screen pt-20">
        <div className="max-w-4xl mx-auto bg-ct-dark-100 rounded-md h-min-[20rem] flex justify-center items-center gap-4 p-4">
          <div className='flex-1 flex flex-col items-center justify-center gap-4'>
            <div className=' w-[200px] h-[200px] rounded-full overflow-hidden relative bg-red-500/20'>
              <img className='object-cover w-full h-full ' src={user?.photo || 'default.png'} />
            </div>

            <div>
              <label htmlFor='upload' className='cursor-pointer text-sm mb-2 text-slate-500 hover:text-white mr-4 py-2 px-4 rounded-full border-0 text-sm font-semibold bg-violet-200 text-violet-700 hover:bg-violet-400'>
                edit
              </label>
              <input
                id="upload"
                type="file"
                className='hidden'
                onChange={uploadImageToCloudinary}
                accept='image/*'
                multiple={false}
              />

            </div>
          </div>

          <div className='flex-1'>
            <p className="text-5xl font-semibold">Profile Page</p>
            <div className="mt-8">
              <p className="mb-4">ID: {user?.id}</p>
              <p className="mb-4">Name: {user?.name}</p>
              <p className="mb-4">Email: {user?.email}</p>
              <p className="mb-4">Role: {user?.role}</p>
            </div>
          </div>

        </div>
      </section>

    </>
  )
}

export default ProfilePage

完整畫面

https://ithelp.ithome.com.tw/upload/images/20231012/20145677tAajGimeR9.png

auth middleware

但這時你發現你成功登入後,再回到 profile page 時候你會發現,user 還是可以進入,所以我們需要一個 validate 機制讓 user 不要造訪該頁面。
https://ithelp.ithome.com.tw/upload/images/20231012/20145677urC4kiJ6Eo.png

這邊我打算用 hook 方式來檢驗~

src/hook/useRefetchToken.ts

useRefreshToken 中主要會是透過打 refreshAccessToken api 方式去驗證 laccessToken 是否過期或是有沒有 validateerror 就登出 success 就保留頁面狀態並同步更新 accessToken

import { useRouter } from "next/router"
import { useEffect } from "react"
import { useStore } from "~/client/store"
import { api } from "~/utils/trpc"

export const useRefreshToken = () => {
  const { setAccessToken } = useStore()
  const router = useRouter()
  const { setPageLoading } = useStore()
  const { data, isLoading, isError } = api.auth.refreshAccessToken.useQuery(undefined, {
    trpc: {
      context: {
        skipBatch: true
      }
    }
  })

  useEffect(() => {
    if (!data?.access_token) return
    setAccessToken(data?.access_token)
  }, [data?.access_token])
  useEffect(() => {
    setPageLoading(isLoading)
  }, [isLoading])
  useEffect(() => {
    if (!isError) return
    router.push('/login')
    setAccessToken('')
  }, [isError])

}

最後在 ProfilePage 加上 useRefreshToken 功能就完成摟~

function ProfilePage() {
  useRefreshToken()
  //..

相關連結

https://github.com/Danny101201/refetch-token/tree/main

✅ 前端社群 :
https://lihi3.cc/kBe0Y


上一篇
Day-027. 一些讓你看來很強的全端 TRPC 伴讀 - zustand & cloundinary
下一篇
Day-029. 一些讓你看來很強的全端 TRPC 伴讀 -api doc
系列文
一些讓你看來很強的全端- trcp 伴讀30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言