diff --git a/README.md b/README.md index cf05305..72f9e87 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ pnpm db:migrate:local pnpm dev ``` -`pnpm dev` builds the app and starts it in Wrangler's local Workers runtime so the D1 binding is available. Set `BETTER_AUTH_SECRET` in `apps/web/.dev.vars` to a long random string (`openssl rand -hex 32`). Sign up with email and password in the app; Google OAuth is tracked in a separate issue. +`pnpm dev` builds the app and starts it in Wrangler's local Workers runtime so the D1 binding is available. Set `BETTER_AUTH_SECRET` in `apps/web/.dev.vars` to a long random string (`openssl rand -hex 32`). Sign up with email and password in the app. GitHub sign-in appears only when `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` are set (see docs/web-app.md). ## Inspecting the local database diff --git a/apps/web/.dev.vars.example b/apps/web/.dev.vars.example index c2f4ab4..e62ae74 100644 --- a/apps/web/.dev.vars.example +++ b/apps/web/.dev.vars.example @@ -1,2 +1,6 @@ BETTER_AUTH_SECRET=replace-with-a-long-random-string BETTER_AUTH_URL=http://localhost:8787 + +# Optional: set both to show "GitHub でログイン". See docs/web-app.md for how to get them. +# GITHUB_CLIENT_ID= +# GITHUB_CLIENT_SECRET= diff --git a/apps/web/src/hooks/use-require-session.ts b/apps/web/src/hooks/use-require-session.ts index 27c02cd..7522d01 100644 --- a/apps/web/src/hooks/use-require-session.ts +++ b/apps/web/src/hooks/use-require-session.ts @@ -8,7 +8,7 @@ export function useRequireSession() { const { data: session, isPending } = authClient.useSession() useEffect(() => { - if (!isPending && !session) void navigate({ to: '/login' }) + if (!isPending && !session) void navigate({ to: '/login', search: { error: undefined } }) }, [isPending, session, navigate]) return session diff --git a/apps/web/src/lib/oauth-error.test.ts b/apps/web/src/lib/oauth-error.test.ts new file mode 100644 index 0000000..0a9adef --- /dev/null +++ b/apps/web/src/lib/oauth-error.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { oauthErrorMessage } from './oauth-error' + +describe('oauthErrorMessage', () => { + it('explains that an address already registered with a password cannot be joined', () => { + expect(oauthErrorMessage('unable_to_link_account')).toContain('メールとパスワードで登録') + }) + + it('explains a GitHub account with no usable email', () => { + expect(oauthErrorMessage('email_not_found')).toContain('メールアドレスがありません') + expect(oauthErrorMessage('email_not_verified')).toContain('確認されていません') + }) + + it.each(['invalid_code', 'no_code', 'anything-else', ''])( + 'falls back to a general message for %j', + (code) => { + expect(oauthErrorMessage(code)).toBe( + 'GitHub でのログインに失敗しました。もう一度試してください。', + ) + }, + ) +}) diff --git a/apps/web/src/lib/oauth-error.ts b/apps/web/src/lib/oauth-error.ts new file mode 100644 index 0000000..bd18042 --- /dev/null +++ b/apps/web/src/lib/oauth-error.ts @@ -0,0 +1,13 @@ +/** What to tell a person whose sign-in with GitHub did not work. `code` is better-auth's `?error=`. */ +export function oauthErrorMessage(code: string) { + switch (code) { + case 'unable_to_link_account': + return 'このメールアドレスは、すでにメールとパスワードで登録されています。メールとパスワードでログインしてください。' + case 'email_not_found': + return 'GitHub のアカウントにメールアドレスがありません。GitHub の設定でメールアドレスを追加してから、もう一度試してください。' + case 'email_not_verified': + return 'GitHub のメールアドレスが確認されていません。GitHub で確認してから、もう一度試してください。' + default: + return 'GitHub でのログインに失敗しました。もう一度試してください。' + } +} diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index e07fe18..1b87b61 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -29,7 +29,7 @@ function Home() { email={session.user.email} onSignOut={async () => { await authClient.signOut() - await navigate({ to: '/login' }) + await navigate({ to: '/login', search: { error: undefined } }) }} /> ) diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx index 0b38178..cd2c6fc 100644 --- a/apps/web/src/routes/login.tsx +++ b/apps/web/src/routes/login.tsx @@ -3,17 +3,41 @@ import { useEffect, useState, type FormEvent } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { authClient } from '@/lib/auth-client' +import { oauthErrorMessage } from '@/lib/oauth-error' export const Route = createFileRoute('/login')({ + // better-auth sends a failed sign-in with GitHub back here as `/login?error=`. + validateSearch: (search: Record) => ({ + error: typeof search.error === 'string' ? search.error : undefined, + }), component: Login, }) function Login() { const navigate = useNavigate() + const { error: oauthError } = Route.useSearch() const { data: session } = authClient.useSession() const [mode, setMode] = useState<'signIn' | 'signUp'>('signIn') const [pending, setPending] = useState(false) const [error, setError] = useState('') + const [github, setGithub] = useState(false) + + // Which sign-in buttons exist depends on what the server has been given keys for. + useEffect(() => { + let active = true + void (async () => { + try { + const response = await fetch('/api/config') + const config = (await response.json()) as { github?: boolean } + if (active) setGithub(config.github === true) + } catch { + // No config means no extra buttons. + } + })() + return () => { + active = false + } + }, []) useEffect(() => { if (session) void navigate({ to: '/' }) @@ -68,15 +92,31 @@ function Login() { minLength={8} required /> - {error && ( + {(error || oauthError) && (

- {error} + {error || (oauthError ? oauthErrorMessage(oauthError) : '')}

)} + {github && ( + + )}