[Next.js][App router]分享使用自幹auth模組的元件及Server Action

因為NextAuth無法直接在Firebase hosting上使用所以開始自幹一個auth模組,這篇分享一些關於模組週邊使用的5項程式碼。

分享以下5類程式碼

  1. AuthSessionProvider.tsx
  2. layout.tsx
  3. middleware.tsx
  4. Server Actions
  5. Components


1. providers/AuthSessionProvider.tsx

'use client';

import { createContext, useContext, useState, useEffect, useMemo } from 'react';
import type { ReactNode } from 'react';
import type { Session } from '@/types/authTypes';

export type AuthSessionContextType = {
session: Session | null;
}

export interface AuthSessionProviderProps {
config: Session | null;
children: ReactNode;
}

const AuthSessionContext = createContext<AuthSessionContextType | null>(null)

export default function AuthSessionProvider({ config, children }: AuthSessionProviderProps) {
const [session, setSession] = useState<Session | null>(config);

useEffect(() => setSession(config), [config]);

const contextData = useMemo<AuthSessionContextType>(() => ({ session }), [session])

return (
<AuthSessionContext.Provider value={contextData} >
{children}
</AuthSessionContext.Provider>
)
}

export function useSession() {
const contextData = useContext(AuthSessionContext) as AuthSessionContextType;

// 確保 context 是空的
if (contextData == null) {
throw new Error('useSession must be used within a AuthSessionProvider');
}

return { user: contextData.session?.user };
}

和layout一起用就能在Client component上顯示一些登入用戶得個人資本資訊。


2. app/(member)/layout.tsx

import * as React from 'react';
import { auth } from '@/auth';
import AuthSessionProvider from '@/providers/AuthSessionProvider';
import { MainLayout } from './MainLayout';

type Props = {
children: React.ReactNode;
};

export default async function MemberLayout(props: Props) {
const session = await auth();
return (
<AuthSessionProvider config={session}>
<MainLayout>
{props.children}
</MainLayout>
</AuthSessionProvider>
);
}

在App router中,可以把function改aync function就能執行server側的auth函數,再把這個資訊帶給client側的元件。


3. middleware.tsx

import { NextRequest, NextResponse } from 'next/server';

export async function middleware(req: NextRequest) {
if (req.nextUrl.pathname.startsWith(AUTH_API_PATH)) {
return NextResponse.next();
}

try {
if (!req.cookies.has(SESSION_COOKIE_NAME)) {
throw new Error('not found session');
}

// 驗證cookie,這裡省略

// 持有已授權的session, 正常進入
return NextResponse.next();
}
catch (error: any) {
// 未授權, 消除cookie
if (req.cookies.has(SESSION_COOKIE_NAME)) {
req.cookies.delete(SESSION_COOKIE_NAME);
}

if (req.nextUrl.pathname.startsWith('/api')) {
return NextResponse.json({
status: 'error',
message: 'UNAUTHORIZED',
}, { status: 401 });
}

return NextResponse.redirect(REDIRECT_LOGIN_PATH);
}
}

export const config = {
matcher: ['/admin/:path*', '/dashboard/:path*'],
}

透過middleware來限制未登入的使用者存取特定頁面


4. Server Actions

authActions.ts
'use server';

import { signIn, signOut } from '@/auth';

export async function onSignIn() {
await signIn();
}

export async function onLineSignIn() {
await signIn('line');
}

export async function onCredentialSignIn(_previousState: any, data: FormData) {
return await signIn('email', undefined, {
username: data.get('username')?.toString(),
password: data.get('password')?.toString(),
});
}

export async function onSignOut() {
await signOut();
}

這裡模仿next-auth的server action們


5. Components

使用Server Action打造的一些元件

5.1 登入按鈕

跳轉到登入頁

import { Tooltip, Button } from '@mui/material';
import { onSignIn } from '@/authActions';

export default function SignInButton() {
return (
<form action={onSignIn}>
<Tooltip title="登入">
<Button type='submit'>用戶登入</Button>
</Tooltip>
</form>
);
}


5.2 帳密登入

'use client';

import { Typography, TextField, Button, Stack } from '@mui/material';
import { useFormState, useFormStatus } from 'react-dom';
import { onCredentialSignIn } from '@/authActions';

export default function CredentialLoginForm() {
const [state, action] = useFormState(onCredentialSignIn, { message: '' });
const { pending } = useFormStatus();
return (
<Stack direction='column' gap={3} component='form' action={action}>
<Typography>{pending ? '登入中...' : ''}</Typography>
<Typography>{state?.message}</Typography>
<TextField
fullWidth required
name='username'
label='電子郵件'
autoComplete='email'
disabled={pending}
/>
<TextField
fullWidth required
name='password'
label='密碼'
autoComplete='current-password'
type='password'
disabled={pending}
/>
<Button
fullWidth
type='submit'
variant='contained'
disabled={pending}
>登入</Button>
</Stack>
);
}

文章開頭的登入頁圖片即有使用這個帳密登入的按鈕


5.3 Line登入按鈕

import Button from '@mui/material/Button';
import { onLineSignIn } from '@/authActions';

export default function LineLoginButton() {
return (
<form action={onLineSignIn}>
<Button type='submit'>LINE登入</Button>
</form>
);
}

文章開頭的登入頁圖片即有使用這個Line登入按鈕


5.4 登出按鈕

import Button from '@mui/material/Button';
import { onSignOut } from '@/authActions';
import { useFormState, useFormStatus } from 'react-dom';

export default function SignOutButton() {
const [_, action] = useFormState(onSignOut, null);
const { pending } = useFormStatus();
return (
<form action={action}>
<Button type='submit' disabled={pending}>登出</Button>
</form>
);
}

和layout, title和footer結合起來就可以變成下面的登出頁和個人選單這樣





留言