✨ User login
This commit is contained in:
parent
66255e1879
commit
6aced3ddf7
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": false,
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all",
|
||||
"singleQuote": true
|
||||
}
|
40
README.md
40
README.md
@ -1,40 +0,0 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/pages/api-reference/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun 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.
|
||||
|
||||
[API routes](https://nextjs.org/docs/pages/building-your-application/routing/api-routes) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
|
||||
|
||||
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/pages/building-your-application/routing/api-routes) instead of React pages.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/pages/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn-pages-router) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/pages/building-your-application/deploying) for more details.
|
@ -13,9 +13,13 @@
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@mui/icons-material": "^6.3.0",
|
||||
"@mui/material": "^6.3.0",
|
||||
"axios": "^1.7.9",
|
||||
"axios-case-converter": "^1.1.1",
|
||||
"cookies-next": "^5.0.2",
|
||||
"next": "15.1.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.54.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
|
@ -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,
|
||||
},
|
||||
)
|
Loading…
Reference in New Issue
Block a user