Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
143 changes: 113 additions & 30 deletions src/client/component/auth/LoginForm.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import usePasskey from '@/client/hook/usePasskey'
import { hcWithType } from '@/server/client'
import { Button, PasswordInput, TextInput } from '@mantine/core'
import { Button, Divider, Group, PasswordInput, TextInput } from '@mantine/core'
import { isNotEmpty, useForm } from '@mantine/form'
import { notifications } from '@mantine/notifications'
import { browserSupportsWebAuthnAutofill } from '@simplewebauthn/browser'
import { IconKeyFilled } from '@tabler/icons-react'
import { useNavigate } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'

export default function LoginForm() {
const [loading, setLoading] = useState<boolean>(false)
const [loginLoading, setLoginLoading] = useState<boolean>(false)
const [passkeyLoading, setPasskeyLoading] = useState<boolean>(false)
const navigate = useNavigate()
const { registerPasskey, loginWithPasskey } = usePasskey()
const manualPasskeyLoginStartedRef = useRef(false)

const form = useForm({
mode: 'controlled',
Expand All @@ -23,8 +29,17 @@ export default function LoginForm() {
})
const client = useMemo(() => hcWithType('/api'), [])

const onLoginSuccess = useCallback(async () => {
notifications.show({
color: 'blue',
title: 'ログインしました',
message: 'トップページに遷移します',
})
await navigate({ to: '/' })
}, [navigate])

const handleLogin = form.onSubmit(async (values) => {
setLoading(true)
setLoginLoading(true)

const res = await client.auth.login.$post({
json: {
Expand All @@ -39,39 +54,107 @@ export default function LoginForm() {
message: 'ID またはパスワードが正しくありません',
})
form.setFieldValue('password', '')
setLoading(false)
setLoginLoading(false)
return
}

notifications.show({
color: 'blue',
title: 'ログインしました',
message: 'トップページに遷移します',
})

// 遷移するまで loading は解除しない
await navigate({ to: '/' })
// 下の処理はあえて待たない
onLoginSuccess()

// 失敗してもログインには影響しないので、UI にエラーを通知したりしない
try {
await registerPasskey()
} catch (e) {
console.log(e)
}
})

const handlePasskeyLogin = async () => {
setPasskeyLoading(true)
manualPasskeyLoginStartedRef.current = true

try {
await loginWithPasskey({ useBrowserAutofill: false })
// 遷移するまで loading は解除しない
await onLoginSuccess()
} catch (e) {
notifications.show({
color: 'red',
title: 'パスキーでのログインに失敗しました',
message: 'もう一度お試しください',
})
setPasskeyLoading(false)
}
}

useEffect(() => {
;(async () => {
if (await browserSupportsWebAuthnAutofill()) {
try {
await loginWithPasskey({ useBrowserAutofill: true })
await onLoginSuccess()
} catch (e) {
/**
* ボタンから手動でログインを始めるとエラーが発生する
* その場合はエラーを通知しない
*
* effect の再実行は不要なため、passkeyLoading を使った判定はできない
* 依存配列から除いても、render 前の値しか参照できない
*/
if (!manualPasskeyLoginStartedRef.current) {
notifications.show({
color: 'red',
title: 'パスキーでのログインに失敗しました',
message: 'もう一度お試しください',
})
}
}
}
})()
}, [onLoginSuccess, loginWithPasskey])

return (
<form onSubmit={handleLogin}>
<TextInput required label='ID' {...form.getInputProps('userId')} />
<PasswordInput
required
label='パスワード'
{...form.getInputProps('password')}
mt='md'
/>
<>
<form onSubmit={handleLogin}>
<TextInput
required
label='ID'
{...form.getInputProps('userId')}
autoComplete='username webauthn'
/>
<PasswordInput
required
label='パスワード'
{...form.getInputProps('password')}
mt='md'
autoComplete='current-password'
/>

<Button
type='submit'
fullWidth
mt='xl'
disabled={!form.isValid()}
loading={loginLoading}
>
ログイン
</Button>
</form>

<Divider label='または' labelPosition='center' my='lg' />

<Button
type='submit'
fullWidth
mt='xl'
disabled={!form.isValid()}
loading={loading}
>
ログイン
</Button>
</form>
<Group grow mb='md' mt='md'>
<Button
fullWidth
color='violet'
leftSection={<IconKeyFilled size='24px' />}
onClick={handlePasskeyLogin}
loading={passkeyLoading}
>
パスキーでログイン
</Button>
</Group>
</>
)
}
81 changes: 81 additions & 0 deletions src/client/hook/usePasskey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { hcWithType } from '@/server/client'
import { startAuthentication, startRegistration } from '@simplewebauthn/browser'
import type {
AuthenticationResponseJSON,
RegistrationResponseJSON,
} from '@simplewebauthn/types'
import { useCallback, useMemo } from 'react'

export default function usePasskey() {
const client = useMemo(() => hcWithType('/api'), [])

// refs: https://simplewebauthn.dev/docs/packages/browser#startregistration
const registerPasskey = useCallback(async () => {
const attestationRes = await client.auth.passkey.attestation.options.$get()
if (!attestationRes.ok) {
throw new Error('Failed to get attestation options')
}
const optionsJSON = await attestationRes.json()

let registrationResponse: RegistrationResponseJSON
try {
registrationResponse = await startRegistration({
optionsJSON,
useAutoRegister: true,
})
} catch (e) {
throw new Error('Failed to start registration')
}

const verificationRes = await client.auth.passkey.attestation.$post({
json: registrationResponse,
})
if (!verificationRes.ok) {
throw new Error('Failed to verify registration response')
}
const { verified } = await verificationRes.json()

if (!verified) {
throw new Error('Failed to verify registration response')
}
}, [client])

// refs: https://simplewebauthn.dev/docs/packages/browser#startauthentication
const loginWithPasskey = useCallback(
async ({ useBrowserAutofill }: { useBrowserAutofill: boolean }) => {
const assertionRes = await client.auth.passkey.assertion.options.$get()
if (!assertionRes.ok) {
throw new Error('Failed to get assertion options')
}
const optionsJSON = await assertionRes.json()

let assertionResponse: AuthenticationResponseJSON
try {
assertionResponse = await startAuthentication({
optionsJSON,
useBrowserAutofill,
})
} catch (e) {
throw new Error('Failed to start authentication')
}

const verificationRes = await client.auth.passkey.assertion.$post({
json: assertionResponse,
})
if (!verificationRes.ok) {
throw new Error('Failed to verify assertion response')
}
const { verified } = await verificationRes.json()

if (!verified) {
throw new Error('Failed to verify assertion response')
}
},
[client],
)

return {
registerPasskey,
loginWithPasskey,
}
}