SvelteKit中使用Firebase Auth

Background

最近一周多,在学习 SvelteKit,并且做了一个小项目,以便把学到的东西串联起来。目前我觉得需要整理、消化一下目前的已有成果,做一个阶段性的总结。

Tech stack

  • ui:skeleton, tailwindcss, tailwindcss/forms, tailwindcss/typography, lucide
  • auth: firebase, firebase-admin
  • form: zod, sveltekit-superforms

vscode 插件:sveltekit-snippets

这个插件对于新手来说太好了,很多时间不熟练咋写一些片段,可以帮助快速生成一些样板代码。

Skeleton

UI 框架选择了 skeleton。我比较了 Daisyui,最后选择了 Skeleton。体验很不错,开箱即用,并且预设主题也很好看,快速做原型的话,可以节省很多在 UI 上的时间。

AppShell 和 AppBar 很适合做页面布局。

<AppShell>
  <svelte:fragment slot="header">
    <AppBar class="mx-auto max-w-5xl" background="">
      <svelte:fragment slot="lead">
        <Logo />
      </svelte:fragment>
      <svelte:fragment slot="trail">
        <header />
      </svelte:fragment>
    </AppBar>
  </svelte:fragment>

  <section class="mx-auto h-full max-w-5xl">
    <slot />
  </section>

  <svelte:fragment slot="footer">
    <footer />
  </svelte:fragment>
</AppShell>

我很喜欢 LightSwitch 这个组件,零配置,太省事了。

<LightSwitch />

还有像 card 和 Tabs 这样的样式和组件,可以极大地提高开发效率。

<div class="flex flex-1 flex-col justify-center max-w-md mx-auto h-full">
    <div class="card px-6 pt-6 pb-8">
        <User2 class="mx-auto mb-5" size="42" />
        <TabGroup justify="justify-center">
            <Tab bind:group={tabSet} name="signInTab" value={'signIn'}>Sign In</Tab>
            <Tab bind:group={tabSet} name="signUpTab" value={'signUp'}>Sign Up</Tab>
            <!-- Tab Panels --->
            <svelte:fragment slot="panel">
                {#if tabSet === 'signIn'}
                    <Signin />
                {:else if tabSet === 'signUp'}
                    <Signup />
                {/if}
            </svelte:fragment>
        </TabGroup>
    </div>
</div>

我是第一次使用 Skeleton,以不多的个人经验来判断,我觉得我会成为粉丝。

Zod

一看就懂的表单验证。

import { z } from 'zod'

const userSchema = z.object({
  email: z.string().trim().email({ message: 'Email is invalid' }),
  password: z
    .string()
    .trim()
    .min(6, { message: 'Password must be at least 6 characters long' }),
})

export function validateUser(user) {
  return userSchema.safeParse(user)
}

zod 返回的错误是一个列表,需要自己处理一下。

const msg = {}
const { success, error } = validateUser({ email, password })
if (success) {
  // validate success
} else {
  error.issues.forEach((issue) => {
    msg[issue.path[0]] = issue.message
  })
}

Firebase auth

Firebase 让我又爱又恨,最终只能归结为自己太菜。我花了很多时间才搞懂这是一个客户端为主的组件。

客户端初始化:

import { initializeApp, getApps } from 'firebase/app'
import { getAuth } from 'firebase/auth'

const firebaseConfig = {
  apiKey: '',
  // something else
}

export const app =
  getApps().length === 0
    ? initializeApp(firebaseConfig, 'Client')
    : getApps()[0]

export const auth = getAuth(app)

邮箱密码登录主要使用了 signInWithEmailAndPasswordcreateUserWithEmailAndPasswordonAuthStateChanged方法。

服务端初始化:

import { initializeApp, cert, getApps } from 'firebase-admin/app'
import { credential } from './firebase-admin-json'

export function adminApp() {
  if (getApps().length === 0) {
    initializeApp({ credential: cert(credential) })
  }
}

Signin form

<script>
  import { validateUser } from '$lib/auth/user-schema'
  import { AlertTriangle } from 'lucide-svelte'
  import { signInWithEmailAndPassword } from 'firebase/auth'
  import { auth } from '$lib/auth/firebase'
  import { goto } from '$app/navigation'
  import { authStore } from '$lib/auth/auth-store'

  let email = ''
  let password = ''
  let msg = {}

  async function handleSubmit() {
    const { success, error } = validateUser({ email, password })
    if (success) {
      try {
        const userCredential = await signInWithEmailAndPassword(
          auth,
          email,
          password
        )
        const user = userCredential.user
        authStore.set({
          isSignined: true,
          user: {
            accessToken: user.accessToken,
            refreshToken: user.refreshToken,
            uid: user.uid,
            email: user.email,
            emailVerified: user.emailVerified,
            displayName: user.displayName,
            photoURL: user.photoURL,
            createdAt: user.metadata.createdAt,
            lastLoginAt: user.metadata.lastLoginAt,
          },
        })
        goto('/')
      } catch (error) {
        msg = {}
        const code = error.code
        if (code === 'auth/email-already-in-use') {
          msg.email = 'Email already in use'
        } else if (code === 'auth/wrong-password') {
          msg.password = 'Wrong password'
        } else if (code === 'auth/user-not-found') {
          msg.email = 'User not found'
        } else {
          msg.password = error.code
        }
      }
    } else {
      error.issues.forEach((issue) => {
        msg[issue.path[0]] = issue.message
      })
    }
  }
</script>

<form
  method="POST"
  on:submit|preventDefault="{handleSubmit}"
  class="mx-auto flex flex-col gap-5"
>
  <div>
    <label for="email" class="label">
      <input
        id="email"
        bind:value="{email}"
        name="email"
        type="email"
        autocomplete="email"
        placeholder="Email"
        class="input"
        required
      />
    </label>
    {#if msg.email}
    <div class="text-error-500 ml-1 mt-3 flex items-center gap-1 text-sm">
      <AlertTriangle size="20" />
      {msg.email}
    </div>
    {/if}
  </div>
  <div>
    <label for="password" class="label">
      <input
        id="password"
        bind:value="{password}"
        name="password"
        type="password"
        autocomplete="new-password"
        placeholder="Password"
        class="input"
        required
        minlength="6"
      />
    </label>
    {#if msg.password}
    <div class="text-error-500 ml-1 mt-3 flex items-center gap-1 text-sm">
      <AlertTriangle size="20" />
      {msg.password}
    </div>
    {/if}
  </div>
  <div>
    <button type="submit" class="btn variant-filled-primary w-full">
      Sign In
    </button>
  </div>
</form>

登录以后将用户信息存储在 authStore 中,这样在其他页面就可以使用了。

Protected route

对于需要保护的路由,在 load 函数中判断用户状态,对未登录做重定向。

// +page.js
import { redirect } from '@sveltejs/kit'
import { authStore } from '$lib/auth/auth-store'

export function load() {
  let isSignined
  const unsubscribe = authStore.subscribe((value) => {
    isSignined = value.isSignined
  })
  unsubscribe()
  if (!isSignined) throw redirect(307, '/signin')

  return {}
}

在+layout.svelte 中,每个页面挂载时,监听用户状态的变化。

<script>
  import { onAuthStateChanged } from 'firebase/auth'
  import { auth } from '$lib/auth/firebase'
  import { authStore } from '$lib/auth/auth-store'
  import { onMount } from 'svelte'

  onMount(() => {
    onAuthStateChanged(auth, (user) => {
      if (user) {
        authStore.set({
          isSignined: user !== null,
          user: {
            accessToken: user.accessToken,
            refreshToken: user.refreshToken,
            uid: user.uid,
            email: user.email,
            emailVerified: user.emailVerified,
            displayName: user.displayName,
            photoURL: user.photoURL,
            createdAt: user.metadata.createdAt,
            lastLoginAt: user.metadata.lastLoginAt,
          },
        })
      } else {
        authStore.set({
          isSignined: false,
          user: null,
        })
      }
    })
  })
</script>

Signout

在全局导航中,可以自动订阅 authStore,根据用户状态显示不同的导航。相比 React 里面的 useState,Skelte 里面的 store 简直太方便了。

在 signOut 退出事件中,从 Firebase 中退出用户,重设 authStore,最后重定向到登录页面。

<script>
    // Header.svelte
    import { page } from '$app/stores';
    import { auth } from '$lib/auth/firebase';
    import { signOut } from 'firebase/auth';
    import { goto } from '$app/navigation';
    import { authStore } from '$lib/auth/auth-store';

    async function handelSignOut() {
        try {
            await signOut(auth);
            authStore.set({ isSignined: false, user: null });
        } catch (error) {
            console.log(error);
        }
        goto('/signin');
    }
</script>

<ul class="flex flex-row gap-4 text-lg font-semibold">
    {#if $authStore.isSignined}
        <li><button on:click={handelSignOut}>Sign Out</button></li>
    {:else}
        <li>
            <a href="/signin"
                class={$page.url.pathname === '/signin' && 'text-primary-600'}>
                Sign In
            </a>
        </li>
    {/if}
</ul>

Wrapping up

目前实现了一个简单的客户端身份验证功能,但是不好的是页面刷新或者打开新的标签页,用户状态会丢失。接下来会改造为服务端的身份验证方案。