Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
/node_modules
/.pnp
.pnp.js
package-lock.json
yarn.lock

# testing
/coverage
Expand Down
74 changes: 74 additions & 0 deletions components/User/SessionBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { Drawer, List, ListItem, ListItemButton, ListItemText } from '@mui/material';
import { observable } from 'mobx';
import { observer } from 'mobx-react';
import Link from 'next/link';
import { Component, HTMLAttributes, JSX } from 'react';

import { PageHead } from '../PageHead';
import { SessionForm } from './SessionForm';

export type MenuItem = Pick<JSX.IntrinsicElements['a'], 'href' | 'title'>;

export interface SessionBoxProps extends HTMLAttributes<HTMLDivElement> {
path?: string;
menu?: MenuItem[];
jwtPayload?: any; // TODO: Define proper JWT payload type
}

@observer
export class SessionBox extends Component<SessionBoxProps> {
@observable
accessor modalShown = false;

componentDidMount() {
this.modalShown = !this.props.jwtPayload;
}

render() {
const { className = '', title, children, path, menu = [], jwtPayload, ...props } = this.props;

return (
<div className={`flex ${className}`} {...props}>
<div>
<List
component="nav"
className="flex-col px-3 sticky-top"
style={{ top: '5rem', minWidth: '200px' }}
>
{menu.map(({ href, title }) => (
<ListItem key={href} disablePadding>
<ListItemButton
component={Link}
href={href || '#'}
selected={path?.split('?')[0].startsWith(href || '')}
className="rounded"
>
<ListItemText primary={title} />
</ListItemButton>
</ListItem>
))}
</List>
</div>
<main className="flex-1 pb-3">
<PageHead title={title} />

<h1 className="text-3xl font-bold mb-4">{title}</h1>

{children}

<Drawer
anchor="right"
open={this.modalShown}
PaperProps={{
className: 'p-4',
style: { width: '400px' },
}}
onClose={() => (this.modalShown = false)}
>
<SessionForm onSignIn={() => window.location.reload()} />
</Drawer>
</main>
</div>
);
}
}
133 changes: 133 additions & 0 deletions components/User/SessionForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { Button, IconButton, InputAdornment, Tab, Tabs, TextField } from '@mui/material';
import { observable } from 'mobx';
import { observer } from 'mobx-react';
import { ObservedComponent } from 'mobx-react-helper';
import { FormEvent, MouseEvent } from 'react';
import { formToJSON } from 'web-utility';

import { i18n, I18nContext } from '../../models/Translation';
import userStore from '../../models/User';
import { SymbolIcon } from '../Icon';

export interface SessionFormProps {
onSignIn?: (data?: SignInData) => any;
}

export interface SignInData {
phone: string;
password: string;
}

@observer
export class SessionForm extends ObservedComponent<SessionFormProps, typeof i18n> {
static contextType = I18nContext;

@observable
accessor signType: 'up' | 'in' = 'in';

handleWebAuthn = async (event: MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();

if (this.signType === 'up') {
const { phone } = formToJSON<SignInData>(event.currentTarget.form!);

if (!phone) throw new Error('手机号是WebAuthn注册的必填项');

await userStore.signUpWebAuthn(phone);
} else {
await userStore.signInWebAuthn();
}
this.props.onSignIn?.();
};

handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
event.stopPropagation();

const { phone, password } = formToJSON<SignInData>(event.currentTarget);

if (this.signType === 'up') {
await userStore.signUp(phone, password);

this.signType = 'in';

alert('注册成功,请登录');
} else {
await userStore.signIn(phone, password);

this.props.onSignIn?.({ phone, password });
}
};

render() {
const { signType } = this,
loading = userStore.uploading > 0;

const { t } = this.observedContext;

return (
<form className="flex flex-col gap-4" onSubmit={this.handleSubmit}>
<Tabs
value={signType}
variant="fullWidth"
className="mb-4"
onChange={(_, newValue: 'up' | 'in') => (this.signType = newValue)}
>
<Tab label={t('register')} value="up" />
<Tab label={t('login')} value="in" />
</Tabs>

<TextField
name="phone"
type="tel"
required
fullWidth
variant="outlined"
label={t('phone_number')}
placeholder={t('please_enter_phone')}
slotProps={{
htmlInput: {
pattern: '1[3-9]\\d{9}',
title: t('please_enter_correct_phone'),
},
input: {
startAdornment: <InputAdornment position="start">+86</InputAdornment>,
},
}}
/>
<div className="flex items-center gap-2">
<TextField
name="password"
type="password"
required
fullWidth
variant="outlined"
label={t('password')}
placeholder={t('please_enter_password')}
/>

<IconButton
size="large"
className="mb-2 self-end"
disabled={loading}
onClick={this.handleWebAuthn}
>
<SymbolIcon name="fingerprint" />
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fingerprint 没有在 html link 处引入,不会生效的

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fingerprint 没有在 html link 处引入,不会生效的

好的,没注意这么细节的问题,主要是先想让 AI 把通用的登录框代码移植过来,马上要做的下个 PR 我修复一下。

</IconButton>
</div>

<Button
className="mt-4"
type="submit"
variant="contained"
fullWidth
size="large"
disabled={loading}
>
{signType === 'up' ? t('register') : t('login')}
</Button>
</form>
);
}
}
24 changes: 24 additions & 0 deletions models/Base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { HTTPClient } from 'koajax';
import MIME from 'mime';
import { githubClient } from 'mobx-github';
import { TableCellValue, TableCellMedia, TableCellAttachment } from 'mobx-lark';
import { Filter, ListModel, toggle, IDType } from 'mobx-restful';
import { buildURLData } from 'web-utility';

import { API_Host, GITHUB_TOKEN, isServer } from './configuration';

Expand Down Expand Up @@ -34,3 +36,25 @@ export function fileURLOf(field: TableCellValue, cache = false) {

return URI;
}

export abstract class TableModel<D extends Base, F extends Filter<D> = Filter<D>> extends ListModel<
D,
F
> {
@toggle('uploading')
async updateOne(data: Filter<D>, id?: IDType) {
const { body } = await (id
? this.client.put<D>(`${this.baseURI}/${id}`, data)
: this.client.post<D>(this.baseURI, data));

return (this.currentOne = body!);
}

async loadPage(pageIndex: number, pageSize: number, filter: F) {
const { body } = await this.client.get<ListChunk<D>>(
`${this.baseURI}?${buildURLData({ ...filter, pageIndex, pageSize })}`,
);

return { pageData: body!.list, totalCount: body!.count };
}
}
117 changes: 117 additions & 0 deletions models/User.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { clear } from 'idb-keyval';
import { HTTPClient } from 'koajax';
import { observable, reaction } from 'mobx';
import { persist, restore, toggle } from 'mobx-restful';
import { setCookie } from 'web-utility';

import { TableModel } from './Base';
import { API_Host, isServer } from './configuration';

export interface User {
id?: string;
email?: string;
nickname?: string;
avatar?: string;
token?: string;
}

export interface WebAuthnChallenge {
string: string;
}

export class UserModel extends TableModel<User> {
baseURI = 'user';

@persist()
@observable
accessor session: User | undefined;

disposer = reaction(
() => this.session?.token,
token => setCookie('token', token || '', { path: '/' }),
);
restored = !isServer() && restore(this, 'User');

client = new HTTPClient({ baseURI: API_Host, responseType: 'json' }).use(({ request }, next) => {
const isSameDomain = API_Host.startsWith(new URL(request.path, API_Host).origin);

if (isSameDomain && this.session)
request.headers = {
...request.headers,
Authorization: `Bearer ${this.session.token}`,
};

return next();
});

@toggle('uploading')
async sendOTP(address: string) {
await this.client.post(`user/session/email/${address}/OTP`);
}

@toggle('uploading')
async signUp(email: string, password: string) {
const { body } = await this.client.post<User>('user', { email, password });

return body;
}

@toggle('uploading')
async signIn(email: string, password: string) {
const { body } = await this.client.post<User>('user/session', { email, password });

return (this.session = body);
}

@toggle('uploading')
async createChallenge() {
const { body } = await this.client.post<WebAuthnChallenge>('user/WebAuthn/challenge');

return body!.string;
}

@toggle('uploading')
async signUpWebAuthn(email: string) {
if (isServer()) throw new Error('WebAuthn not available on server side');

const { client } = await import('@passwordless-id/webauthn');

const challenge = await this.createChallenge();

const registration = await client.register({ user: email, challenge });

const { body } = await this.client.post<User>('user/WebAuthn/registration', {
...registration,
challenge,
});

return (this.session = body);
}

@toggle('uploading')
async signInWebAuthn() {
if (isServer()) throw new Error('WebAuthn not available on server side');

const { client } = await import('@passwordless-id/webauthn');

const challenge = await this.createChallenge();

const authentication = await client.authenticate({ challenge });

const { body } = await this.client.post<User>('user/WebAuthn/authentication', {
...authentication,
challenge,
});

return (this.session = body);
}

@toggle('uploading')
async signOut() {
await clear();

location.hash = '';
}
}

export default new UserModel();
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@
"@mui/lab": "^7.0.0-beta.16",
"@mui/material": "^7.3.1",
"@mui/material-nextjs": "^7.3.0",
"@passwordless-id/webauthn": "^2.3.1",
"@sentry/nextjs": "^10.8.0",
"file-type": "^21.0.0",
"idb-keyval": "^6.2.2",
"jsonwebtoken": "^9.0.2",
"koa": "^3.0.1",
"koa-jwt": "^4.0.4",
Expand Down
Loading
Loading