dh_demo

DreamHanks demo project
git clone git://git.lair.cx/dh_demo
Log | Files | Refs | README

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:
MREADME.md | 2+-
Mcomponents/contexts/TokenContext.tsx | 20++++++++++++++++++--
Mcomponents/form/Field.module.css | 2+-
Mcomponents/form/Field.module.css.map | 4++--
Mcomponents/form/Field.module.scss | 51++++++++++++++++++++++++++++++++++++++++-----------
Mcomponents/form/Field.tsx | 60++++++++++++++++++++++++++++++++++++++----------------------
Mcomponents/form/Fields.module.css | 2+-
Mcomponents/form/Fields.module.scss | 2+-
Mcomponents/layout/Header.tsx | 9+++++++--
Mlib/apierror.ts | 22+++++++++++++++++++++-
Alib/hooks/use_api.ts | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlib/models/login_info.ts | 24++++++++++++++++++++++++
Mlib/models/user_profile.ts | 8+++++++-
Mlib/security/token.ts | 58+++++++++++++++++++++++++++++++++++++++++++++++-----------
Mpages/_app.tsx | 13++++++++-----
Apages/api/users/[id].ts | 40++++++++++++++++++++++++++++++++++++++++
Mpages/api/users/signup/[id].ts | 6+++---
Mpages/api/users/signup/index.ts | 4+++-
Mpages/index.tsx | 11++++-------
Dpages/login/index.tsx | 69---------------------------------------------------------------------
Apages/users/login.tsx | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apages/users/signup/[id].tsx | 149+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apages/users/signup/index.tsx | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apages/users/signup/sent.tsx | 22++++++++++++++++++++++
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> + </> + ) +} +