commit 92cf119b97ded7e447df69e75efefb24dfe92a03
parent 8ee33f32bdcc3942ea1ebd5bd7027251cb5bc556
Author: Yongbin Kim <iam@yongbin.kim>
Date: Thu, 19 Jan 2023 11:44:18 +0900
feat: 회원가입 기능 추가 및 폴더 정리
Signed-off-by: Yongbin Kim <iam@yongbin.kim>
Diffstat:
24 files changed, 687 insertions(+), 141 deletions(-)
diff --git a/README.md b/README.md
@@ -14,7 +14,7 @@ pnpm dev
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
-You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
+You can start editing the page by modifying `pages/login.tsx`. The page auto-updates as you edit the file.
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
diff --git a/components/contexts/TokenContext.tsx b/components/contexts/TokenContext.tsx
@@ -1,6 +1,6 @@
import { getAccessTokenCookieName } from '@/lib/env'
import { decode, JwtPayload } from 'jsonwebtoken'
-import { createContext, ReactNode, useCallback, useEffect, useRef, useState } from 'react'
+import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react'
const regexCookie = /^([^=]*)=([^;]*)(?:;\s*|$)/
@@ -12,6 +12,7 @@ export interface AccessTokenPayload extends JwtPayload {
}
const TokenContext = createContext<AccessTokenPayload | null>(null)
+const TokenRefreshContext = createContext<() => void>(() => {})
export function TokenProvider ({ children }: { children: ReactNode }) {
const [accessToken, setAccessToken] = useState<AccessTokenPayload | null>(null)
@@ -63,13 +64,28 @@ export function TokenProvider ({ children }: { children: ReactNode }) {
}
}, [accessToken, accessToken?.exp])
+ const trigger = useCallback(
+ () => setCheckCounter(v => v + 1),
+ []
+ )
+
return (
<TokenContext.Provider value={accessToken}>
- {children}
+ <TokenRefreshContext.Provider value={trigger}>
+ {children}
+ </TokenRefreshContext.Provider>
</TokenContext.Provider>
)
}
+export function useToken () {
+ return useContext(TokenContext)
+}
+
+export function useTokenRefreshTrigger () {
+ return useContext(TokenRefreshContext)
+}
+
function getCookie (name: string) {
name = name.toLowerCase()
diff --git a/components/form/Field.module.css b/components/form/Field.module.css
@@ -1 +1 @@
-.field{display:flex;position:relative;border:1px solid rgba(0,0,0,0);margin-bottom:1rem;border-radius:4px;background-color:inherit;box-shadow:0 0 0 1px rgba(0,0,0,0);transition:all 200ms;color:#191c1b;border-color:#6f7975}.field:focus-within{border-color:#006b5a;box-shadow:0 0 0 1px #006b5a}.field.is-disabled{color:rgba(25,28,27,.5);border-color:rgba(111,121,117,.5)}@media(prefers-color-scheme: dark){.field{color:#e1e3e0;border-color:#89938f}.field:focus-within{border-color:#2cdebf;box-shadow:0 0 0 1px #2cdebf}.field.is-disabled{color:rgba(225,227,224,.5);border-color:rgba(137,147,143,.5)}}.input{width:100%;height:3.5rem;border:0;padding:0 1rem;color:inherit;background:rgba(0,0,0,0);outline:none}.field.has-icon-left .input{padding-left:3.25rem}.field.has-icon-right .input{padding-right:3.25rem}.label-background{position:absolute;top:-3px;left:.6875rem;height:4px;padding:0 .3125rem;line-height:0;color:rgba(0,0,0,0);background-color:inherit;transform:translateX(-10%) scaleX(0);transition:200ms transform ease-in-out}.field.has-icon-left .label-background{left:.75rem}.field:focus-within .label-background,.field.has-content .label-background{transform:translateX(-10%) scaleX(0.8)}.label{position:absolute;top:50%;left:1rem;pointer-events:none;transform-origin:left top;transform:translateY(-50%);transition:all .2s ease-in-out;z-index:1;color:#3f4946}.field.has-icon-left .label{left:.75rem}.field:focus-within .label,.field.has-content .label{top:0;transform:scale(0.8) translateY(-50%)}.field:focus-within .label{color:#006b5a}.field.is-disabled .label{color:rgba(63,73,70,.5)}@media(prefers-color-scheme: dark){.label{color:#bfc9c4}.field:focus-within .label{color:#2cdebf}.field.is-disabled .label{color:rgba(191,201,196,.5)}}/*# sourceMappingURL=Field.module.css.map */
+.field{display:flex;position:relative;border:1px solid rgba(0,0,0,0);margin-bottom:1rem;border-radius:4px;background-color:inherit;box-shadow:0 0 0 1px rgba(0,0,0,0);transition:all 200ms;color:#191c1b;border-color:#6f7975}.field.is-disabled{color:rgba(25,28,27,.5);border-color:rgba(111,121,117,.5)}.field.is-not-readonly.has-no-color:focus-within{border-color:#006b5a;box-shadow:0 0 0 1px #006b5a}.field.is-primary{color:#006b5a;border-color:#006b5a}.field.is-primary:focus-within{box-shadow:0 0 0 1px #006b5a}.field.is-secondary{color:#7b4998;border-color:#7b4998}.field.is-secondary:focus-within{box-shadow:0 0 0 1px #7b4998}.field.is-tertiary{color:#426278;border-color:#426278}.field.is-tertiary:focus-within{box-shadow:0 0 0 1px #426278}.field.is-error{color:red;border-color:red}.field.is-error:focus-within{box-shadow:0 0 0 1px red}@media(prefers-color-scheme: dark){.field{color:#e1e3e0;border-color:#89938f}.field.is-disabled{color:rgba(225,227,224,.5);border-color:rgba(137,147,143,.5)}.field.is-not-readonly.has-no-color:focus-within{border-color:#2cdebf;box-shadow:0 0 0 1px #2cdebf}.field.is-primary{color:#2cdebf;border-color:#2cdebf}.field.is-primary:focus-within{box-shadow:0 0 0 1px #2cdebf}.field.is-secondary{color:#e6b4ff;border-color:#e6b4ff}.field.is-secondary:focus-within{box-shadow:0 0 0 1px #e6b4ff}.field.is-tertiary{color:#aacbe4;border-color:#aacbe4}.field.is-tertiary:focus-within{box-shadow:0 0 0 1px #aacbe4}.field.is-error{color:#ffb4ab;border-color:#ffb4ab}.field.is-error:focus-within{box-shadow:0 0 0 1px #ffb4ab}}.input{width:100%;height:3.5rem;border:0;padding:0 1rem;color:inherit;background:rgba(0,0,0,0);outline:none}.field.has-icon-left .input{padding-left:3.25rem}.field.has-icon-right .input{padding-right:3.25rem}.label-background{position:absolute;top:-3px;left:.6875rem;height:4px;padding:0 .3125rem;line-height:0;color:rgba(0,0,0,0);background-color:inherit;transform:translateX(-10%) scaleX(0);transition:200ms transform ease-in-out}.field.is-not-readonly:focus-within .label-background,.field.has-content .label-background{transform:translateX(-10%) scaleX(0.8)}.label{position:absolute;top:50%;left:1rem;pointer-events:none;transform-origin:left top;transform:translateY(-50%);transition:all .2s ease-in-out;z-index:1;color:#3f4946}.field.has-icon-left .label{left:.75rem}.field.is-not-readonly:focus-within .label,.field.has-content .label{top:0;transform:scale(0.8) translateY(-50%)}.field.is-not-readonly.has-no-color:focus-within .label{color:#006b5a}.field.is-disabled .label{color:rgba(63,73,70,.5)}.is-primary .label{color:#006b5a}.is-secondary .label{color:#7b4998}.is-tertiary .label{color:#426278}.is-error .label{color:red}@media(prefers-color-scheme: dark){.label{color:#bfc9c4}.field.is-not-readonly.has-no-color:focus-within .label{color:#2cdebf}.field.is-disabled .label{color:rgba(191,201,196,.5)}.is-primary .label{color:#2cdebf}.is-secondary .label{color:#e6b4ff}.is-tertiary .label{color:#aacbe4}.is-error .label{color:#ffb4ab}}.message{line-height:1.25rem;font-size:.875rem;letter-spacing:.0178571429rem;font-weight:400;margin-top:-0.5rem;color:#3f4946}.is-primary .message{color:#006b5a}.is-secondary .message{color:#7b4998}.is-tertiary .message{color:#426278}.is-error .message{color:red}@media(prefers-color-scheme: dark){.message{color:#bfc9c4}.is-primary .message{color:#2cdebf}.is-secondary .message{color:#e6b4ff}.is-tertiary .message{color:#aacbe4}.is-error .message{color:#ffb4ab}}/*# sourceMappingURL=Field.module.css.map */
diff --git a/components/form/Field.module.css.map b/components/form/Field.module.css.map
@@ -1 +1 @@
-{"version":3,"sourceRoot":"","sources":["Field.module.scss","../../styles/core/_colors.scss"],"names":[],"mappings":"AAUA,OACE,aACA,kBACA,+BACA,mBACA,kBACA,yBACA,mCACA,qBAGE,cACA,qBAEA,oBACE,qBACA,6BAGF,mBACE,wBACA,kCCiGJ,mCDtHF,OAWI,cACA,qBAEA,oBACE,qBACA,6BAGF,mBACE,2BACA,mCAKN,OACE,WACA,OAnCO,OAoCP,SACA,eACA,cACA,yBACA,aAIA,4BACE,aAHwB,QAM1B,6BACE,cAPwB,QAW5B,kBAGE,kBACA,SACA,cACA,WACA,mBACA,cACA,oBACA,yBACA,qCACA,uCAEA,uCACE,KAhEgB,OAmElB,2EAEE,uCAIJ,OACE,kBACA,QACA,KA7EqB,KA8ErB,oBACA,0BACA,2BACA,+BACA,UAaE,cAXF,4BACE,KApFgB,OAuFlB,qDAEE,MACA,sCAMA,2BACE,cAGF,0BACE,wBCoBJ,mCDhDF,OAqBI,cAEA,2BACE,cAGF,0BACE","file":"Field.module.css"}
-\ No newline at end of file
+{"version":3,"sourceRoot":"","sources":["Field.module.scss","../../styles/core/_colors.scss","../../styles/core/_typography.scss"],"names":[],"mappings":"AAWA,OACE,aACA,kBACA,+BACA,mBACA,kBACA,yBACA,mCACA,qBAGE,cACA,qBAEA,mBACE,wBACA,kCAGF,iDACE,qBACA,6BAIA,kBACE,MC2GM,QD1GN,aC0GM,QDxGN,+BACE,6BALJ,oBACE,MC2GM,QD1GN,aC0GM,QDxGN,iCACE,6BALJ,mBACE,MC2GM,QD1GN,aC0GM,QDxGN,gCACE,6BALJ,gBACE,MC2GM,ID1GN,aC0GM,IDxGN,6BACE,yBCuFR,mCDrHF,OAWI,cACA,qBAEA,mBACE,2BACA,kCAGF,iDACE,qBACA,6BAIA,kBACE,MC2GM,QD1GN,aC0GM,QDxGN,+BACE,6BALJ,oBACE,MC2GM,QD1GN,aC0GM,QDxGN,iCACE,6BALJ,mBACE,MC2GM,QD1GN,aC0GM,QDxGN,gCACE,6BALJ,gBACE,MC2GM,QD1GN,aC0GM,QDxGN,6BACE,8BAOV,OACE,WACA,OA9CO,OA+CP,SACA,eACA,cACA,yBACA,aAIA,4BACE,aAHwB,QAM1B,6BACE,cAPwB,QAW5B,kBAGE,kBACA,SACA,cACA,WACA,mBACA,cACA,oBACA,yBACA,qCACA,uCAEA,2FAEE,uCAIJ,OACE,kBACA,QACA,KApFqB,KAqFrB,oBACA,0BACA,2BACA,+BACA,UAaE,cAXF,4BACE,KA3FgB,OA8FlB,qEAEE,MACA,sCAMA,wDACE,cAGF,0BACE,wBAIA,mBACE,MCuBM,QDxBR,qBACE,MCuBM,QDxBR,oBACE,MCuBM,QDxBR,iBACE,MCuBM,IAhBZ,mCDxCF,OAqBI,cAEA,wDACE,cAGF,0BACE,2BAIA,mBACE,MCuBM,QDxBR,qBACE,MCuBM,QDxBR,oBACE,MCuBM,QDxBR,iBACE,MCuBM,SDjBd,SETI,oBACA,kBACA,8BACA,gBFQF,mBAGE,cAGE,qBACE,MCQM,QDTR,uBACE,MCQM,QDTR,sBACE,MCQM,QDTR,mBACE,MCQM,IAhBZ,mCDDF,SAKI,cAGE,qBACE,MCQM,QDTR,uBACE,MCQM,QDTR,sBACE,MCQM,QDTR,mBACE,MCQM","file":"Field.module.css"}
+\ No newline at end of file
diff --git a/components/form/Field.module.scss b/components/form/Field.module.scss
@@ -1,5 +1,6 @@
@use 'sass:math';
@use 'core/colors';
+@use 'core/typography';
$height: 3.5rem;
$icon-size: 1.5rem;
@@ -22,14 +23,25 @@ $label-focused-scale: 0.8;
color: colors.get($theme, 'on-surface');
border-color: colors.get($theme, 'outline');
- &:focus-within {
+ &.is-disabled {
+ color: rgba(colors.get($theme, 'on-surface'), 0.5);
+ border-color: rgba(colors.get($theme, 'outline'), 0.5);
+ }
+
+ &.is-not-readonly.has-no-color:focus-within {
border-color: colors.get($theme, 'primary');
box-shadow: 0 0 0 1px colors.get($theme, 'primary');
}
- &.is-disabled {
- color: rgba(colors.get($theme, 'on-surface'), 0.5);
- border-color: rgba(colors.get($theme, 'outline'), 0.5);
+ @include colors.sets($theme) using ($key, $color, $color-on) {
+ &.is-#{$key} {
+ color: $color;
+ border-color: $color;
+
+ &:focus-within {
+ box-shadow: 0 0 0 1px $color;
+ }
+ }
}
}
}
@@ -68,11 +80,7 @@ $label-focused-scale: 0.8;
transform: translateX(-10%) scaleX(0);
transition: 200ms transform ease-in-out;
- .field.has-icon-left & {
- left: $padding-with-icon;
- }
-
- .field:focus-within &,
+ .field.is-not-readonly:focus-within &,
.field.has-content & {
transform: translateX(-10%) scaleX($label-focused-scale);
}
@@ -92,7 +100,7 @@ $label-focused-scale: 0.8;
left: $padding-with-icon;
}
- .field:focus-within &,
+ .field.is-not-readonly:focus-within &,
.field.has-content & {
top: 0;
transform: scale($label-focused-scale) translateY(-50%);
@@ -101,12 +109,33 @@ $label-focused-scale: 0.8;
@include colors.apply-themes using ($theme) {
color: colors.get($theme, 'on-surface-variant');
- .field:focus-within & {
+ .field.is-not-readonly.has-no-color:focus-within & {
color: colors.get($theme, 'primary');
}
.field.is-disabled & {
color: rgba(colors.get($theme, 'on-surface-variant'), 0.5);
}
+
+ @include colors.sets($theme) using ($key, $color, $color-on) {
+ .is-#{$key} & {
+ color: $color;
+ }
+ }
+ }
+}
+
+.message {
+ @include typography.apply('body-medium');
+ margin-top: -0.5rem;
+
+ @include colors.apply-themes using ($theme) {
+ color: colors.get($theme, 'on-surface-variant');
+
+ @include colors.sets($theme) using ($key, $color, $color-on) {
+ .is-#{$key} & {
+ color: $color;
+ }
+ }
}
}
diff --git a/components/form/Field.tsx b/components/form/Field.tsx
@@ -3,9 +3,12 @@ import { FormEvent, HTMLAttributes, useMemo } from 'react'
import styles from './Field.module.css'
export interface FieldProps extends HTMLAttributes<HTMLLabelElement> {
+ color?: 'primary' | 'secondary' | 'tertiary' | 'error'
+ disabled?: boolean
+ message?: string
+ readOnly?: boolean
type: 'date' | 'datetime-local' | 'email' | 'month' | 'number'
| 'password' | 'search' | 'tel' | 'time' | 'url' | 'week' | 'text'
- disabled?: boolean
value?: string
onValueChange?: (value: string) => void
@@ -16,10 +19,13 @@ export interface FieldProps extends HTMLAttributes<HTMLLabelElement> {
// Parent of this component is required to have background-color.
export default function Field (props: FieldProps) {
const {
- className: originalClassName,
+ color,
+ className: className,
+ disabled,
+ message,
placeholder,
+ readOnly,
type,
- disabled,
value: initialValue,
onValueChange,
...restProps
@@ -30,24 +36,34 @@ export default function Field (props: FieldProps) {
}
return (
- <label
- {...classNames(
- originalClassName,
- styles['field'],
- (props.value?.length ?? 0) > 0 ? styles['has-content'] : null,
- disabled === true ? styles['is-disabled'] : null,
- )}
- {...restProps}
- >
- <input
- className={styles['input']}
- type={type}
- disabled={disabled}
- value={props.value}
- onInput={handleInput}
- />
- <span className={styles['label']}>{placeholder}</span>
- <div className={styles['label-background']}>{placeholder}</div>
- </label>
+ <>
+ <label
+ {...classNames(
+ className,
+ styles['field'],
+ (props.value?.length ?? 0) > 0 ? styles['has-content'] : null,
+ color == null ? styles[`has-no-color`] : undefined,
+ color != null ? styles[`is-${color}`] : undefined,
+ readOnly !== true ? styles['is-not-readonly'] : null,
+ disabled === true ? styles['is-disabled'] : null,
+ )}
+ {...restProps}
+ >
+ <input
+ className={styles['input']}
+ type={type}
+ disabled={disabled}
+ readOnly={readOnly}
+ value={props.value}
+ onInput={handleInput}
+ />
+ <span className={styles['label']}>{placeholder}</span>
+ <div className={styles['label-background']}>{placeholder}</div>
+ </label>
+
+ {message != null ? (
+ <div className={styles['message']}>{message}</div>
+ ) : null}
+ </>
)
}
diff --git a/components/form/Fields.module.css b/components/form/Fields.module.css
@@ -1 +1 @@
-.fields-wrapper{max-width:20rem;background-color:#fafdfa;color:#191c1b}@media(prefers-color-scheme: dark){.fields-wrapper{background-color:#191c1b;color:#e1e3e0}}/*# sourceMappingURL=Fields.module.css.map */
+.fields-wrapper{max-width:25rem;background-color:#fafdfa;color:#191c1b}@media(prefers-color-scheme: dark){.fields-wrapper{background-color:#191c1b;color:#e1e3e0}}/*# sourceMappingURL=Fields.module.css.map */
diff --git a/components/form/Fields.module.scss b/components/form/Fields.module.scss
@@ -1,6 +1,6 @@
@use 'core/colors';
-$max-width: 20rem;
+$max-width: 25rem;
.fields-wrapper {
max-width: $max-width;
diff --git a/components/layout/Header.tsx b/components/layout/Header.tsx
@@ -1,14 +1,19 @@
import Navbar from '@/components/layout/Navbar'
import NavbarItem from '@/components/layout/NavbarItem'
+import { ApiError } from '@/lib/apierror'
import classNames from '@/lib/classnames'
+import { UserProfile } from '@/lib/models/user_profile'
import styles from './Header.module.css'
export default function Header () {
+ // 유저 정보
+ // const [userData, isUserDataLoading, , fetchUserData] = useApi<UserInfo | ApiError, void>(
+
return (
<header {...classNames(styles.header)}>
<Navbar>
- <NavbarItem href={'/login'}>Login</NavbarItem>
- <NavbarItem href={'/register'}>Register</NavbarItem>
+ <NavbarItem href={'/users/login'}>Login</NavbarItem>
+ <NavbarItem href={'/users/signup'}>Sign up</NavbarItem>
</Navbar>
</header>
)
diff --git a/lib/apierror.ts b/lib/apierror.ts
@@ -1,9 +1,29 @@
export interface ApiError {
- code?: string
+ code: string
message?: string
+ field?: string
+}
+
+export function isApiError(obj: any): obj is ApiError {
+ return obj.code != null
}
export const ERR_METHOD_NOT_ALLOWED: ApiError = {
code: 'method_not_allowed',
message: 'Method Not Allowed',
}
+
+export const ERR_INVALID_REQUEST: ApiError = {
+ code: 'invalid_request',
+ message: 'Invalid request',
+}
+
+export const ERR_UNAUTHORIZED: ApiError = {
+ code: 'unauthorized',
+ message: 'Unauthorized',
+}
+
+export const ERR_NOT_FOUND: ApiError = {
+ code: 'not_found',
+ message: 'Not Found',
+}
diff --git a/lib/hooks/use_api.ts b/lib/hooks/use_api.ts
@@ -0,0 +1,53 @@
+import { DependencyList, useCallback, useEffect, useMemo, useRef, useState } from 'react'
+
+export interface UseApiOptions<TBody> extends Omit<RequestInit, 'body'> {
+ body?: (() => TBody) | TBody
+}
+
+export default function useApi<T, TBody extends object> (
+ req: RequestInfo | URL,
+ opt?: UseApiOptions<TBody>,
+): [T | null, boolean, number, () => void] {
+ const [isLoading, setLoading] = useState(false)
+ const [statusCode, setStatusCode] = useState(0)
+ const [data, setData] = useState<T | null>(null)
+
+ const { body, ...options } = opt ?? {}
+
+ const doFetch = useCallback(async () => {
+ setLoading(true)
+
+ const init: RequestInit = {
+ ...options,
+ }
+
+ if (body != null) {
+ init.body = JSON.stringify(
+ typeof body === 'function'
+ ? body()
+ : body,
+ )
+ }
+
+ if (init.body != null) {
+ init.headers = {
+ 'Content-Type': 'application/json',
+ ...init.headers,
+ }
+ }
+
+ const resp = await fetch(req, init)
+
+ setStatusCode(resp.status)
+ setData(await resp.json())
+
+ setLoading(false)
+ }, [opt, req])
+
+ const trigger = useCallback(() => {
+ doFetch()
+ .catch((err) => console.error('api error:', err))
+ }, [doFetch])
+
+ return [data, isLoading, statusCode, trigger]
+}
diff --git a/lib/models/login_info.ts b/lib/models/login_info.ts
@@ -9,6 +9,30 @@ export interface LoginInfo {
updatedAt: Date
}
+const SQL_GET_LOGIN_INFO = `
+ SELECT id, email, password_hash, created_at, updated_at
+ FROM logins
+ WHERE id = ?
+ LIMIT 1
+`
+
+export async function getLoginInfo (id: number): Promise<LoginInfo | null> {
+ const [rows] = await db.query<RowDataPacket[]>(SQL_GET_LOGIN_INFO, [id])
+ if (rows.length === 0) {
+ return null
+ }
+
+ const row = rows[0]
+
+ return {
+ id: row[0],
+ email: row[1],
+ passwordHash: row[2],
+ createdAt: row[3],
+ updatedAt: row[4],
+ }
+}
+
const SQL_GET_LOGIN_INFO_VIA_EMAIL = `
SELECT id, email, password_hash, created_at, updated_at
FROM logins
diff --git a/lib/models/user_profile.ts b/lib/models/user_profile.ts
@@ -40,5 +40,11 @@ export async function getUserProfile (loginId: number): Promise<UserProfile | nu
return null
}
- return rows[0] as UserProfile
+ const row = rows[0]
+ return {
+ login_id: row[0],
+ nickname: row[1],
+ bio: row[2],
+ updated_at: row[3],
+ }
}
diff --git a/lib/security/token.ts b/lib/security/token.ts
@@ -1,27 +1,29 @@
-import { LoginInfo } from '@/lib/models/login_info'
-import { sign } from 'jsonwebtoken'
+import { sign, verify, decode } from 'jsonwebtoken'
import { nanoid } from 'nanoid'
const TOKEN_ALGORITHM = 'HS256'
-const TOKEN_EXPIRES_IN = '5m'
-const REFRESH_TOKEN_EXPIRES_IN = '30d'
+export const TOKEN_EXPIRES_IN = 5 * 60 * 1000 // 5분
+export const REFRESH_TOKEN_EXPIRES_IN = 30 * 24 * 60 * 60 * 1000 // 30일
+
+export function getTokenSecret () {
+ return process.env.TOKEN_SECRET ?? 'dangerously_insecure_s3cr3t'
+}
/**
* JWT를 발급합니다.
* @returns [accessToken, refreshToken, tokenId]
*/
export function signToken (
- loginInfo: LoginInfo,
- secret: string,
+ uid: number
): [string, string, string] {
const tokenId = nanoid()
const accessToken = sign(
{
tid: tokenId,
- uid: loginInfo.id,
+ uid: uid,
},
- secret,
+ getTokenSecret(),
{
algorithm: TOKEN_ALGORITHM,
expiresIn: TOKEN_EXPIRES_IN,
@@ -31,10 +33,10 @@ export function signToken (
const refreshToken = sign(
{
tid: tokenId,
- uid: loginInfo.id,
+ uid: uid,
refresh: true,
},
- secret,
+ getTokenSecret(),
{
algorithm: TOKEN_ALGORITHM,
expiresIn: REFRESH_TOKEN_EXPIRES_IN,
@@ -44,6 +46,40 @@ export function signToken (
return [
accessToken,
refreshToken,
- tokenId
+ tokenId,
]
}
+
+export async function verifyToken (token: string) {
+ return new Promise((resolve, reject) => {
+ verify(token, getTokenSecret(), {
+ algorithms: [TOKEN_ALGORITHM],
+ }, (err, decoded) => {
+ if (err != null) {
+ reject(err)
+ return
+ }
+
+ resolve(decoded)
+ })
+ })
+}
+
+export async function getUserIdFromAccessToken (accessToken?: string) {
+ if (accessToken == null) {
+ return null
+ }
+
+ const decoded = await new Promise((resolve, reject) => {
+ verify(accessToken, getTokenSecret(), (err, decoded) => {
+ if (err) {
+ reject(err)
+ } else {
+ resolve(decoded)
+ }
+ })
+ })
+ return typeof decoded === 'object' && decoded !== null && 'uid' in decoded
+ ? decoded.uid as string
+ : null
+}
diff --git a/pages/_app.tsx b/pages/_app.tsx
@@ -1,12 +1,15 @@
import '@/styles/globals.css'
+import { TokenProvider } from '@/components/contexts/TokenContext'
import Header from '@/components/layout/Header'
import type { AppProps } from 'next/app'
-export default function App({ Component, pageProps }: AppProps) {
+export default function App ({ Component, pageProps }: AppProps) {
return (
- <div>
- <Header />
- <Component {...pageProps} />
- </div>
+ <TokenProvider>
+ <div>
+ <Header/>
+ <Component {...pageProps} />
+ </div>
+ </TokenProvider>
)
}
diff --git a/pages/api/users/[id].ts b/pages/api/users/[id].ts
@@ -0,0 +1,40 @@
+import { ERR_INVALID_REQUEST, ERR_METHOD_NOT_ALLOWED, ERR_NOT_FOUND, ERR_UNAUTHORIZED } from '@/lib/apierror'
+import { getAccessTokenCookieName } from '@/lib/env'
+import { getUserProfile } from '@/lib/models/user_profile'
+import { getUserIdFromAccessToken } from '@/lib/security/token'
+import { NextApiRequest, NextApiResponse } from 'next'
+
+export default async function handler (
+ req: NextApiRequest,
+ res: NextApiResponse,
+) {
+ if (req.method !== 'GET') {
+ res.status(405).json(ERR_METHOD_NOT_ALLOWED)
+ return
+ }
+
+ let id = req.query.id
+ if (typeof id !== 'string') {
+ res.status(400).json(ERR_INVALID_REQUEST)
+ return
+ }
+
+ // id가 me일 경우, 현재 로그인한 사용자로 대체
+ if (id == 'me') {
+ const currentUserId = await getUserIdFromAccessToken(req.cookies[getAccessTokenCookieName()])
+ if (currentUserId == null) {
+ res.status(401).json(ERR_UNAUTHORIZED)
+ return
+ }
+ id = currentUserId
+ }
+
+ // 사용자 정보를 받아옴
+ const profile = await getUserProfile(parseInt(id, 10))
+ if (profile == null) {
+ res.status(404).json(ERR_NOT_FOUND)
+ return
+ }
+
+ res.status(200).json(profile)
+}
diff --git a/pages/api/users/signup/[id].ts b/pages/api/users/signup/[id].ts
@@ -34,14 +34,14 @@ export default async function handler (
const { password, nickname } = req.body as SignupRequest
if (password == null || password === '') {
res.status(400).json({
- code: 'ERR_INVALID_PASSWORD',
+ code: 'invalid_password',
message: 'password is required',
})
return
}
if (nickname == null || nickname === '') {
res.status(400).json({
- code: 'ERR_INVALID_NICKNAME',
+ code: 'invalid_nickname',
message: 'nickname is required',
})
return
@@ -51,7 +51,7 @@ export default async function handler (
const signupRequest = await getUnconfirmedSignupRequest(id)
if (signupRequest == null) {
res.status(404).json({
- code: 'ERR_SIGNUP_REQUEST_NOT_FOUND',
+ code: 'signup_request_not_found',
message: 'signup request was not found or expired',
})
return
diff --git a/pages/api/users/signup/index.ts b/pages/api/users/signup/index.ts
@@ -4,10 +4,12 @@ import { getSignupConfirmationEmailHTML } from '@/lib/email_templates'
import { createSignupRequest } from '@/lib/models/signup_request'
import { NextApiRequest, NextApiResponse } from 'next'
+export type SignUpResponse = { status: 'ok' } | ApiError
+
// POST /api/users/signup creates a new signup request
export default async function handler (
req: NextApiRequest,
- res: NextApiResponse<{ status: 'ok' } | ApiError>,
+ res: NextApiResponse<SignUpResponse>,
) {
if (req.method !== 'POST') {
res.status(405).json(ERR_METHOD_NOT_ALLOWED)
diff --git a/pages/index.tsx b/pages/index.tsx
@@ -1,12 +1,9 @@
import Container from '@/components/layout/Container'
-import { Inter } from '@next/font/google'
-export default function Home() {
+export default function Home () {
return (
- <>
- <Container>
- <p>Hello, World!</p>
- </Container>
- </>
+ <Container>
+ <p>Hello, World!</p>
+ </Container>
)
}
diff --git a/pages/login/index.tsx b/pages/login/index.tsx
@@ -1,69 +0,0 @@
-import Button from '@/components/elements/Button'
-import Title from '@/components/elements/Title'
-import Field from '@/components/form/Field'
-import Fields from '@/components/form/Fields'
-import Container from '@/components/layout/Container'
-import Hero from '@/components/layout/Hero'
-import Section from '@/components/layout/Section'
-import { MouseEvent, useState } from 'react'
-
-export default function LoginPage() {
- return (
- <>
- <Hero>
- <Title kind="headline">Login</Title>
- </Hero>
-
- <Section>
- <Container>
- <LoginForm />
- </Container>
- </Section>
- </>
- )
-}
-
-function LoginForm () {
- const [email, setEmail] = useState('')
- const [password, setPassword] = useState('')
-
- const [isLoading, setLoading] = useState(false)
- const [error, setError] = useState<Error | null>(null)
-
- async function handleSubmit (e: MouseEvent<HTMLButtonElement>) {
- if (isLoading) {
- return
- }
-
- setLoading(true)
-
- console.log('TODO: Login')
- }
-
- return (
- <Fields>
- <Field
- type="text"
- disabled={isLoading}
- placeholder="Email"
- value={email}
- onValueChange={setEmail}
- />
- <Field
- type="password"
- disabled={isLoading}
- placeholder="Password"
- value={password}
- onValueChange={setPassword}
- />
-
- <Button
- color="primary"
- disabled={isLoading}
- onClick={handleSubmit}
- >
- Login
- </Button>
- </Fields>
- )
-}
diff --git a/pages/users/login.tsx b/pages/users/login.tsx
@@ -0,0 +1,101 @@
+import Button from '@/components/elements/Button'
+import Title from '@/components/elements/Title'
+import Field from '@/components/form/Field'
+import Fields from '@/components/form/Fields'
+import Container from '@/components/layout/Container'
+import Hero from '@/components/layout/Hero'
+import Section from '@/components/layout/Section'
+import { ApiError, isApiError } from '@/lib/apierror'
+import useApi from '@/lib/hooks/use_api'
+import { TokenResponse } from '@/pages/api/auth/token'
+import { useRouter } from 'next/router'
+import { MouseEvent, useCallback, useEffect, useState } from 'react'
+
+export default function LoginPage () {
+ return (
+ <>
+ <Hero>
+ <Title kind="headline">Login</Title>
+ </Hero>
+
+ <Section>
+ <Container>
+ <LoginForm />
+ </Container>
+ </Section>
+ </>
+ )
+}
+
+function LoginForm () {
+ const router = useRouter()
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+
+ const [err, setError] = useState<ApiError | null>(null)
+ const [result, isLoading, statusCode, submit] = useApi<TokenResponse | ApiError, { email: string, password: string }>(
+ '/api/auth/token',
+ {
+ method: 'POST',
+ credentials: 'same-origin',
+ body: () => ({ email, password })
+ },
+ )
+
+ const handleSubmit = useCallback((e: MouseEvent<HTMLButtonElement>) => {
+ if (isLoading) {
+ return
+ }
+
+ submit()
+ }, [email, password])
+
+ // 로그인 요청 응답 처리
+ useEffect(() => {
+ if (result == null) {
+ return
+ }
+
+ if (isApiError(result)) {
+ setError(result)
+ return
+ } else {
+ setError(null)
+ }
+
+ // 메인 페이지(혹은 redirect_to)로 이동
+ typeof router.query.redirect_to === 'string'
+ ? router.push(router.query.redirect_to as string)
+ : router.push('/')
+ }, [result])
+
+ return (
+ <Fields>
+ <Field
+ type="text"
+ color={err?.code != null ? 'error' : undefined}
+ disabled={isLoading || result != null}
+ placeholder="Email"
+ value={email}
+ onValueChange={setEmail}
+ />
+ <Field
+ type="password"
+ color={err?.code != null ? 'error' : undefined}
+ disabled={isLoading || result != null}
+ placeholder="Password"
+ value={password}
+ message={err?.code}
+ onValueChange={setPassword}
+ />
+
+ <Button
+ color="primary"
+ disabled={isLoading}
+ onClick={handleSubmit}
+ >
+ Login
+ </Button>
+ </Fields>
+ )
+}
diff --git a/pages/users/signup/[id].tsx b/pages/users/signup/[id].tsx
@@ -0,0 +1,149 @@
+import Button from '@/components/elements/Button'
+import Title from '@/components/elements/Title'
+import Field from '@/components/form/Field'
+import Fields from '@/components/form/Fields'
+import Container from '@/components/layout/Container'
+import Hero from '@/components/layout/Hero'
+import Section from '@/components/layout/Section'
+import { isApiError } from '@/lib/apierror'
+import { getUnconfirmedSignupRequest } from '@/lib/models/signup_request'
+import { GetServerSideProps } from 'next'
+import { useRouter } from 'next/router'
+import { useEffect, useState } from 'react'
+
+interface PageProps {
+ id: number
+ email: string
+}
+
+export const getServerSideProps: GetServerSideProps<PageProps> = async (context) => {
+ let id: number
+ try {
+ id = parseInt(context.query.id as string, 10)
+ } catch (e) {
+ return {
+ notFound: true,
+ }
+ }
+
+ const signUpRequest = await getUnconfirmedSignupRequest(id)
+ if (signUpRequest == null) {
+ return {
+ notFound: true,
+ }
+ }
+
+ return {
+ props: {
+ id,
+ email: signUpRequest.email,
+ },
+ }
+}
+
+export default function SignUpCompletePage (props: PageProps) {
+ const router = useRouter()
+ const requestId = router.query.id as string
+
+ const [password, setPassword] = useState('')
+ const [passwordError, setPasswordError] = useState<string | null>(null)
+ const [passwordRepeat, setPasswordRepeat] = useState('')
+ const [passwordRepeatError, setPasswordRepeatError] = useState<string | null>(null)
+ const [nickname, setNickname] = useState('')
+ const [nicknameError, setNicknameError] = useState<string | null>(null)
+
+ // 비밀번호 확인 오류 표시
+ useEffect(() => {
+ if (passwordRepeat?.length === 0) {
+ return
+ }
+ setPasswordRepeatError(
+ password !== passwordRepeat
+ ? 'password-unmatched'
+ : null,
+ )
+ }, [password, passwordRepeat])
+
+ const doSignUp = async () => {
+ const res = await fetch(`/api/users/signup/${requestId}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ password,
+ nickname,
+ }),
+ })
+
+ if (res.ok) {
+ await router.push('/users/login')
+ return
+ }
+
+ const data = await res.json()
+ if (isApiError(data)) {
+ switch (data.field) {
+ case 'nickname':
+ setNicknameError(data.code)
+ break
+
+ default:
+ setPasswordError(data.code)
+ break
+ }
+ }
+ }
+
+ return (
+ <>
+ <Hero>
+ <Title kind="headline">Sign up</Title>
+ </Hero>
+
+ <Section>
+ <Container>
+ <Fields>
+ <Field
+ type="text"
+ placeholder="Email"
+ readOnly
+ value={props.email}
+ />
+ <Field
+ type="password"
+ placeholder="Password"
+ color={passwordError ? 'error' : undefined}
+ message={passwordError ?? undefined}
+ value={password}
+ onValueChange={setPassword}
+ />
+ <Field
+ type="password"
+ placeholder="Password (Repeat)"
+ color={passwordRepeatError ? 'error' : undefined}
+ message={passwordRepeatError ?? undefined}
+ value={passwordRepeat}
+ onValueChange={setPasswordRepeat}
+ />
+ <Field
+ type="text"
+ placeholder="Nickname"
+ color={nicknameError ? 'error' : undefined}
+ message={nicknameError ?? undefined}
+ value={nickname}
+ onValueChange={setNickname}
+ />
+ </Fields>
+
+ <Button
+ color="primary"
+ onClick={doSignUp}
+ >
+ Sign up
+ </Button>
+ </Container>
+ </Section>
+ </>
+ )
+}
diff --git a/pages/users/signup/index.tsx b/pages/users/signup/index.tsx
@@ -0,0 +1,96 @@
+import Button from '@/components/elements/Button'
+import Title from '@/components/elements/Title'
+import Field from '@/components/form/Field'
+import Fields from '@/components/form/Fields'
+import Container from '@/components/layout/Container'
+import Hero from '@/components/layout/Hero'
+import Section from '@/components/layout/Section'
+import { ApiError } from '@/lib/apierror'
+import useApi from '@/lib/hooks/use_api'
+import { SignUpResponse } from '@/pages/api/users/signup'
+import { useRouter } from 'next/router'
+import { MouseEvent, useEffect, useMemo, useState } from 'react'
+
+export default function SignUpPage () {
+ return (
+ <>
+ <Hero>
+ <Title kind="headline">Sign up</Title>
+ </Hero>
+
+ <Section>
+ <Container>
+ <SignUpForm/>
+ </Container>
+ </Section>
+ </>
+ )
+}
+
+function SignUpForm () {
+ const router = useRouter()
+ const [email, setEmail] = useState('')
+ const [result, isLoading, statusCode, submit] = useApi<SignUpResponse, { email: string }>(
+ '/api/users/signup',
+ {
+ method: 'POST',
+ body: () => ({ email })
+ },
+ )
+
+ const err = useMemo(() => {
+ if (result == null || 'status' in result) {
+ return null
+ }
+ return result
+ }, [result])
+
+ const handleSubmit = (e: MouseEvent<HTMLButtonElement>) => {
+ if (isLoading) {
+ return
+ }
+
+ submit()
+ }
+
+ useEffect(() => {
+ if (result == null) {
+ return
+ }
+
+ if (!('status' in result)) {
+ return
+ }
+
+ router.push('/users/signup/sent')
+ .catch(console.error)
+ }, [result])
+
+ return (
+ <>
+ <Fields>
+ <Field
+ type="text"
+ disabled={isLoading || result != null}
+ placeholder="Email"
+ color={err == null ? undefined : 'error'}
+ value={email}
+ message={err == null ? undefined : err.code}
+ onValueChange={setEmail}
+ />
+ </Fields>
+
+ <p>입력해주신 주소로 인증 메일을 보내드립니다.
+ 메일에 포함된 링크를 통해, 가입을 진행할 수 있습니다.</p>
+ <p>메일 주소는 공개되지 않습니다.</p>
+
+ <Button
+ color="primary"
+ disabled={isLoading || result != null}
+ onClick={handleSubmit}
+ >
+ Sign up
+ </Button>
+ </>
+ )
+}
diff --git a/pages/users/signup/sent.tsx b/pages/users/signup/sent.tsx
@@ -0,0 +1,22 @@
+import Title from '@/components/elements/Title'
+import Container from '@/components/layout/Container'
+import Hero from '@/components/layout/Hero'
+import Section from '@/components/layout/Section'
+
+export default function SignUpSentPage () {
+ return (
+ <>
+ <Hero>
+ <Title kind="headline">Sign up</Title>
+ </Hero>
+
+ <Section>
+ <Container>
+ <p>메일을 발송했습니다.</p>
+ <p>메일의 링크를 클릭하여 계속 진행해주세요.</p>
+ </Container>
+ </Section>
+ </>
+ )
+}
+