User login

This commit is contained in:
LittleSheep 2025-01-01 22:44:35 +08:00
parent 66255e1879
commit 6aced3ddf7
14 changed files with 399 additions and 83 deletions

7
.prettierrc Normal file
View File

@ -0,0 +1,7 @@
{
"semi": false,
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "all",
"singleQuote": true
}

View File

@ -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.

BIN
bun.lockb

Binary file not shown.

View File

@ -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",

View File

@ -1,24 +1,23 @@
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 }}>
<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"
@ -28,8 +27,9 @@ export function CapAppBar() {
>
<AccountCircle />
</IconButton>
</Link>
</Toolbar>
</AppBar>
</Box>
);
)
}

View 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>
</>
)
}

View 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>
</>
)
}

View 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>
</>
)
}

View File

@ -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 />
<Box sx={{ height: 'calc(100vh - 64px)' }}>
<Component {...pageProps} />
</Box>
</ThemeProvider>
</>
);
)
}

View File

@ -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
View 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>
)
}

View File

@ -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
View 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
View 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,
},
)