✨ User login
This commit is contained in:
		| @@ -1,35 +1,35 @@ | ||||
| import { AppBar, Box, IconButton, Toolbar, Typography } from "@mui/material"; | ||||
| import MenuIcon from "@mui/icons-material/Menu"; | ||||
| import AccountCircle from "@mui/icons-material/AccountCircle"; | ||||
| import { AppBar, Box, IconButton, Toolbar, Typography } from '@mui/material' | ||||
| import MenuIcon from '@mui/icons-material/Menu' | ||||
| import AccountCircle from '@mui/icons-material/AccountCircle' | ||||
| import Link from 'next/link' | ||||
|  | ||||
| export function CapAppBar() { | ||||
|   return ( | ||||
|     <Box sx={{ flexGrow: 1 }}> | ||||
|       <AppBar position="static" elevation={0} color="transparent"> | ||||
|         <Toolbar> | ||||
|           <IconButton | ||||
|             size="large" | ||||
|             edge="start" | ||||
|             color="inherit" | ||||
|             aria-label="menu" | ||||
|             sx={{ mr: 2 }} | ||||
|           > | ||||
|           <IconButton size="large" edge="start" color="inherit" aria-label="menu" sx={{ mr: 2 }}> | ||||
|             <MenuIcon /> | ||||
|           </IconButton> | ||||
|           <Typography variant="h6" component="div" sx={{ flexGrow: 1 }}> | ||||
|             Capital | ||||
|           </Typography> | ||||
|           <IconButton | ||||
|             size="large" | ||||
|             aria-label="account of current user" | ||||
|             aria-controls="primary-search-account-menu" | ||||
|             aria-haspopup="true" | ||||
|             color="inherit" | ||||
|           > | ||||
|             <AccountCircle /> | ||||
|           </IconButton> | ||||
|           <Link href="/" passHref style={{ flexGrow: 1 }}> | ||||
|             <Typography variant="h6" component="div"> | ||||
|               Capital | ||||
|             </Typography> | ||||
|           </Link> | ||||
|  | ||||
|           <Link href="/auth/login" passHref> | ||||
|             <IconButton | ||||
|               size="large" | ||||
|               aria-label="account of current user" | ||||
|               aria-controls="primary-search-account-menu" | ||||
|               aria-haspopup="true" | ||||
|               color="inherit" | ||||
|             > | ||||
|               <AccountCircle /> | ||||
|             </IconButton> | ||||
|           </Link> | ||||
|         </Toolbar> | ||||
|       </AppBar> | ||||
|     </Box> | ||||
|   ); | ||||
|   ) | ||||
| } | ||||
|   | ||||
							
								
								
									
										83
									
								
								src/components/auth/SnLoginCheckpoint.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								src/components/auth/SnLoginCheckpoint.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,83 @@ | ||||
| 'use client' | ||||
|  | ||||
| import { SnAuthFactor, SnAuthResult, SnAuthTicket } from '@/services/auth' | ||||
| import { sni } from '@/services/network' | ||||
| import { ArrowForward } from '@mui/icons-material' | ||||
| import { Collapse, Alert, Box, TextField, Button } from '@mui/material' | ||||
| import { useState } from 'react' | ||||
| import { useForm } from 'react-hook-form' | ||||
|  | ||||
| import ErrorIcon from '@mui/icons-material/Error' | ||||
| import { setCookie } from 'cookies-next/client' | ||||
|  | ||||
| export interface SnLoginCheckpointForm { | ||||
|   password: string | ||||
| } | ||||
|  | ||||
| export function SnLoginCheckpoint({ | ||||
|   ticket, | ||||
|   factor, | ||||
|   onNext, | ||||
| }: { | ||||
|   ticket: SnAuthTicket | ||||
|   factor: SnAuthFactor | ||||
|   onNext: (val: SnAuthTicket, done: boolean) => void | ||||
| }) { | ||||
|   const [error, setError] = useState<string | null>(null) | ||||
|   const [loading, setLoading] = useState<boolean>(false) | ||||
|  | ||||
|   const { handleSubmit, register } = useForm<SnLoginCheckpointForm>() | ||||
|  | ||||
|   async function onSubmit(data: any) { | ||||
|     try { | ||||
|       setLoading(true) | ||||
|       const resp = await sni.patch<SnAuthResult>('/cgi/id/auth', { | ||||
|         ticket_id: ticket.id, | ||||
|         factor_id: factor.id, | ||||
|         code: data.password, | ||||
|       }) | ||||
|  | ||||
|       if (resp.data.isFinished) { | ||||
|         const tokenResp = await sni.post('/cgi/id/auth/token', { | ||||
|           grant_type: 'grant_token', | ||||
|           code: resp.data.ticket.grantToken!, | ||||
|         }) | ||||
|         const atk: string = tokenResp.data['accessToken'] | ||||
|         const rtk: string = tokenResp.data['refreshToken'] | ||||
|         setCookie('nex_user_atk', atk, { path: '/', maxAge: 2592000 }) | ||||
|         setCookie('nex_user_rtk', rtk, { path: '/', maxAge: 2592000 }) | ||||
|         console.log('[Authenticator] User has been logged in. Result atk: ', atk) | ||||
|       } | ||||
|  | ||||
|       onNext(resp.data.ticket, resp.data.isFinished) | ||||
|     } catch (err: any) { | ||||
|       setError(err.toString()) | ||||
|     } finally { | ||||
|       setLoading(false) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Collapse in={!!error} sx={{ width: 320 }}> | ||||
|         <Alert sx={{ mb: 4 }} icon={<ErrorIcon fontSize="inherit" />} severity="error"> | ||||
|           {error} | ||||
|         </Alert> | ||||
|       </Collapse> | ||||
|  | ||||
|       <form onSubmit={handleSubmit(onSubmit)}> | ||||
|         <Box sx={{ display: 'flex', flexDirection: 'column', width: 320, gap: 2, textAlign: 'center' }}> | ||||
|           <TextField | ||||
|             label={factor.type == 0 ? 'Password' : 'Verification code'} | ||||
|             type="password" | ||||
|             {...register('password', { required: true })} | ||||
|           /> | ||||
|  | ||||
|           <Button variant="contained" endIcon={<ArrowForward />} disabled={loading} type="submit"> | ||||
|             Next | ||||
|           </Button> | ||||
|         </Box> | ||||
|       </form> | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
							
								
								
									
										68
									
								
								src/components/auth/SnLoginRouter.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								src/components/auth/SnLoginRouter.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,68 @@ | ||||
| 'use client' | ||||
|  | ||||
| import { SnAuthFactor, SnAuthTicket } from '@/services/auth' | ||||
| import { sni } from '@/services/network' | ||||
| import { Collapse, Alert, Box, Button, Typography, ButtonGroup } from '@mui/material' | ||||
| import { useState } from 'react' | ||||
|  | ||||
| import ErrorIcon from '@mui/icons-material/Error' | ||||
| import PasswordIcon from '@mui/icons-material/Password' | ||||
| import EmailIcon from '@mui/icons-material/Email' | ||||
|  | ||||
| export function SnLoginRouter({ | ||||
|   ticket, | ||||
|   factorList, | ||||
|   onNext, | ||||
| }: { | ||||
|   ticket: SnAuthTicket | ||||
|   factorList: SnAuthFactor[] | ||||
|   onNext: (val: SnAuthFactor) => void | ||||
| }) { | ||||
|   const factorTypeIcons = [<PasswordIcon />, <EmailIcon />] | ||||
|   const factorTypeLabels = ['Password', 'Email verification code'] | ||||
|  | ||||
|   const [error, setError] = useState<string | null>(null) | ||||
|   const [loading, setLoading] = useState<boolean>(false) | ||||
|  | ||||
|   async function onSubmit(factor: SnAuthFactor) { | ||||
|     try { | ||||
|       setLoading(true) | ||||
|       await sni.post('/cgi/id/auth/factors/' + factor.id) | ||||
|       onNext(factor) | ||||
|     } catch (err: any) { | ||||
|       setError(err.toString()) | ||||
|     } finally { | ||||
|       setLoading(false) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Collapse in={!!error} sx={{ width: 320 }}> | ||||
|         <Alert sx={{ mb: 4 }} icon={<ErrorIcon fontSize="inherit" />} severity="error"> | ||||
|           {error} | ||||
|         </Alert> | ||||
|       </Collapse> | ||||
|  | ||||
|       <Box sx={{ display: 'flex', flexDirection: 'column', width: 320, gap: 2, textAlign: 'center' }}> | ||||
|         <ButtonGroup orientation="vertical" aria-label="Vertical button group"> | ||||
|           {factorList.map((factor) => ( | ||||
|             <Button | ||||
|               sx={{ py: 1 }} | ||||
|               key={factor.id} | ||||
|               onClick={() => onSubmit(factor)} | ||||
|               disabled={loading || ticket.factorTrail?.includes(factor.id)} | ||||
|               startIcon={factorTypeIcons[factor.type]} | ||||
|             > | ||||
|               {factorTypeLabels[factor.type]} | ||||
|             </Button> | ||||
|           ))} | ||||
|         </ButtonGroup> | ||||
|  | ||||
|         <Typography variant="caption" sx={{ opacity: 0.75, mx: 2 }}> | ||||
|           {ticket.stepRemain} step(s) left | ||||
|         </Typography> | ||||
|       </Box> | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
							
								
								
									
										67
									
								
								src/components/auth/SnLoginStart.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								src/components/auth/SnLoginStart.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,67 @@ | ||||
| 'use client' | ||||
|  | ||||
| import { useState } from 'react' | ||||
| import { sni } from '@/services/network' | ||||
| import { ArrowForward } from '@mui/icons-material' | ||||
| import { Alert, Box, Button, Collapse, Link, TextField, Typography } from '@mui/material' | ||||
| import { SnAuthFactor, SnAuthResult, SnAuthTicket } from '@/services/auth' | ||||
| import { useForm } from 'react-hook-form' | ||||
|  | ||||
| import ErrorIcon from '@mui/icons-material/Error' | ||||
|  | ||||
| export type SnLoginStartForm = { | ||||
|   username: string | ||||
| } | ||||
|  | ||||
| export function SnLoginStart({ onNext }: { onNext: (val: SnAuthTicket, fcs: SnAuthFactor[]) => void }) { | ||||
|   const [error, setError] = useState<string | null>(null) | ||||
|   const [loading, setLoading] = useState<boolean>(false) | ||||
|  | ||||
|   const { handleSubmit, register } = useForm<SnLoginStartForm>() | ||||
|  | ||||
|   async function onSubmit(data: any) { | ||||
|     try { | ||||
|       setLoading(true) | ||||
|       const resp = await sni.post<SnAuthResult>('/cgi/id/auth', data) | ||||
|       const factorResp = await sni.get<SnAuthFactor[]>('/cgi/id/auth/factors', { | ||||
|         params: { | ||||
|           ticketId: resp.data.ticket.id, | ||||
|         }, | ||||
|       }) | ||||
|       onNext(resp.data.ticket, factorResp.data) | ||||
|     } catch (err: any) { | ||||
|       setError(err.toString()) | ||||
|     } finally { | ||||
|       setLoading(false) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Collapse in={!!error} sx={{ width: 320 }}> | ||||
|         <Alert sx={{ mb: 4 }} icon={<ErrorIcon fontSize="inherit" />} severity="error"> | ||||
|           {error} | ||||
|         </Alert> | ||||
|       </Collapse> | ||||
|  | ||||
|       <form onSubmit={handleSubmit(onSubmit)}> | ||||
|         <Box sx={{ display: 'flex', flexDirection: 'column', width: 320, gap: 2, textAlign: 'center' }}> | ||||
|           <TextField | ||||
|             label="Username" | ||||
|             helperText="You can also use email address and phone number" | ||||
|             {...register('username', { required: true })} | ||||
|           /> | ||||
|  | ||||
|           <Button variant="contained" endIcon={<ArrowForward />} disabled={loading} type="submit"> | ||||
|             Next | ||||
|           </Button> | ||||
|  | ||||
|           <Typography variant="caption" sx={{ opacity: 0.75, mx: 2 }}> | ||||
|             By continuing means you agree to our <Link href="#">Terms of Service</Link> and{' '} | ||||
|             <Link href="#">Privacy Policy</Link> | ||||
|           </Typography> | ||||
|         </Box> | ||||
|       </form> | ||||
|     </> | ||||
|   ) | ||||
| } | ||||
| @@ -1,26 +1,26 @@ | ||||
| import "@/styles/globals.css"; | ||||
| import type { AppProps } from "next/app"; | ||||
| import { createTheme, CssBaseline, ThemeProvider } from "@mui/material"; | ||||
| import { Roboto } from "next/font/google"; | ||||
| import { CapAppBar } from "@/components/CapAppBar"; | ||||
| import '@/styles/globals.css' | ||||
| import type { AppProps } from 'next/app' | ||||
| import { Box, createTheme, CssBaseline, ThemeProvider } from '@mui/material' | ||||
| import { Roboto } from 'next/font/google' | ||||
| import { CapAppBar } from '@/components/CapAppBar' | ||||
|  | ||||
| const fontRoboto = Roboto({ | ||||
|   subsets: ["latin"], | ||||
|   weight: ["400", "500", "700"], | ||||
|   display: "swap", | ||||
| }); | ||||
|   subsets: ['latin'], | ||||
|   weight: ['400', '500', '700'], | ||||
|   display: 'swap', | ||||
| }) | ||||
|  | ||||
| const siteTheme = createTheme({ | ||||
|   palette: { | ||||
|     mode: "light", | ||||
|     mode: 'light', | ||||
|     primary: { | ||||
|       main: "#3949ab", | ||||
|       main: '#3949ab', | ||||
|     }, | ||||
|     secondary: { | ||||
|       main: "#1e88e5", | ||||
|       main: '#1e88e5', | ||||
|     }, | ||||
|   }, | ||||
| }); | ||||
| }) | ||||
|  | ||||
| export default function App({ Component, pageProps }: AppProps) { | ||||
|   return ( | ||||
| @@ -35,8 +35,10 @@ export default function App({ Component, pageProps }: AppProps) { | ||||
|         <CssBaseline /> | ||||
|  | ||||
|         <CapAppBar /> | ||||
|         <Component {...pageProps} /> | ||||
|         <Box sx={{ height: 'calc(100vh - 64px)' }}> | ||||
|           <Component {...pageProps} /> | ||||
|         </Box> | ||||
|       </ThemeProvider> | ||||
|     </> | ||||
|   ); | ||||
|   ) | ||||
| } | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { Html, Head, Main, NextScript } from "next/document"; | ||||
| import { Html, Head, Main, NextScript } from 'next/document' | ||||
|  | ||||
| export default function Document() { | ||||
|   return ( | ||||
| @@ -9,5 +9,5 @@ export default function Document() { | ||||
|         <NextScript /> | ||||
|       </body> | ||||
|     </Html> | ||||
|   ); | ||||
|   ) | ||||
| } | ||||
|   | ||||
							
								
								
									
										79
									
								
								src/pages/auth/login.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								src/pages/auth/login.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,79 @@ | ||||
| import { SnLoginCheckpoint } from '@/components/auth/SnLoginCheckpoint' | ||||
| import { SnLoginRouter } from '@/components/auth/SnLoginRouter' | ||||
| import { SnLoginStart } from '@/components/auth/SnLoginStart' | ||||
| import { SnAuthFactor, SnAuthTicket } from '@/services/auth' | ||||
| import { Box, Container, Typography } from '@mui/material' | ||||
| import { useRouter } from 'next/router' | ||||
| import { useState } from 'react' | ||||
|  | ||||
| export default function Login() { | ||||
|   const [period, setPeriod] = useState<number>(0) | ||||
|   const [ticket, setTicket] = useState<SnAuthTicket | null>(null) | ||||
|   const [factorList, setFactorList] = useState<SnAuthFactor[]>([]) | ||||
|   const [factor, setFactor] = useState<SnAuthFactor | null>(null) | ||||
|  | ||||
|   const router = useRouter() | ||||
|  | ||||
|   function renderForm() { | ||||
|     switch (period) { | ||||
|       case 1: | ||||
|         return ( | ||||
|           <SnLoginRouter | ||||
|             ticket={ticket!} | ||||
|             factorList={factorList} | ||||
|             onNext={(val) => { | ||||
|               setPeriod(period + 1) | ||||
|               setFactor(val) | ||||
|             }} | ||||
|           /> | ||||
|         ) | ||||
|       case 2: | ||||
|         return ( | ||||
|           <SnLoginCheckpoint | ||||
|             ticket={ticket!} | ||||
|             factor={factor!} | ||||
|             onNext={(val, done) => { | ||||
|               if (!done) { | ||||
|                 setTicket(val) | ||||
|                 setPeriod(1) | ||||
|                 return | ||||
|               } | ||||
|               router.push('/') | ||||
|             }} | ||||
|           /> | ||||
|         ) | ||||
|       default: | ||||
|         return ( | ||||
|           <SnLoginStart | ||||
|             onNext={(val, fcs) => { | ||||
|               setPeriod(period + 1) | ||||
|               setTicket(val) | ||||
|               setFactorList(fcs) | ||||
|             }} | ||||
|           /> | ||||
|         ) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <Container | ||||
|       sx={{ | ||||
|         display: 'grid', | ||||
|         placeItems: 'center', | ||||
|         height: '100%', | ||||
|         textAlign: 'center', | ||||
|       }} | ||||
|     > | ||||
|       <Box> | ||||
|         <Typography variant="h5" component="h1"> | ||||
|           Login | ||||
|         </Typography> | ||||
|         <Typography variant="subtitle2" component="h2"> | ||||
|           Login via Solarpass | ||||
|         </Typography> | ||||
|  | ||||
|         <Box sx={{ mt: 3 }}>{renderForm()}</Box> | ||||
|       </Box> | ||||
|     </Container> | ||||
|   ) | ||||
| } | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { Container, Typography } from "@mui/material"; | ||||
| import { Container, Typography } from '@mui/material' | ||||
|  | ||||
| export default function Home() { | ||||
|   return ( | ||||
| @@ -10,5 +10,5 @@ export default function Home() { | ||||
|         </Typography> | ||||
|       </Container> | ||||
|     </main> | ||||
|   ); | ||||
|   ) | ||||
| } | ||||
|   | ||||
							
								
								
									
										34
									
								
								src/services/auth.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/services/auth.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | ||||
| export interface SnAuthResult { | ||||
|   isFinished: boolean | ||||
|   ticket: SnAuthTicket | ||||
| } | ||||
|  | ||||
| export interface SnAuthTicket { | ||||
|   id: number | ||||
|   createdAt: Date | ||||
|   updatedAt: Date | ||||
|   deletedAt?: Date | null | ||||
|   stepRemain: number | ||||
|   grantToken?: string | null | ||||
|   accessToken?: string | null | ||||
|   refreshToken?: string | null | ||||
|   ipAddress: string | ||||
|   location: string | ||||
|   userAgent: string | ||||
|   expiredAt?: Date | null | ||||
|   lastGrantAt?: Date | null | ||||
|   availableAt?: Date | null | ||||
|   nonce?: string | null | ||||
|   accountId?: number | null | ||||
|   factorTrail: number[] | ||||
| } | ||||
|  | ||||
| export interface SnAuthFactor { | ||||
|   id: number | ||||
|   createdAt: Date | ||||
|   updatedAt: Date | ||||
|   deletedAt?: Date | null | ||||
|   type: number | ||||
|   config?: Record<string, any> | null | ||||
|   accountId?: number | null | ||||
| } | ||||
							
								
								
									
										12
									
								
								src/services/network.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/services/network.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| import axios from 'axios' | ||||
| import applyCaseMiddleware from 'axios-case-converter' | ||||
|  | ||||
| export let sni = applyCaseMiddleware( | ||||
|   axios.create({ | ||||
|     baseURL: 'https://api.sn.solsynth.dev', | ||||
|   }), | ||||
|   { | ||||
|     ignoreParams: true, | ||||
|     ignoreHeaders: true, | ||||
|   }, | ||||
| ) | ||||
		Reference in New Issue
	
	Block a user