checkpoint
This commit is contained in:
parent
199dab69f9
commit
9439681df9
4 changed files with 296 additions and 227 deletions
|
|
@ -1,110 +1,113 @@
|
|||
import { useState, useEffect, type ReactNode } from 'react'
|
||||
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
|
||||
import '../styles/auth.css'
|
||||
import { useState, useEffect, type ReactNode } from "react";
|
||||
import {
|
||||
startRegistration,
|
||||
startAuthentication,
|
||||
} from "@simplewebauthn/browser";
|
||||
import "../styles/auth.css";
|
||||
|
||||
type User = { id: number; username: string }
|
||||
type User = { id: number; username: string };
|
||||
|
||||
type Props = {
|
||||
children: (user: User, onLogout: () => void) => ReactNode
|
||||
}
|
||||
children: (user: User, onLogout: () => void) => ReactNode;
|
||||
};
|
||||
|
||||
export function AuthGate({ children }: Props) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [tab, setTab] = useState<'register' | 'login'>('login')
|
||||
const [username, setUsername] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tab, setTab] = useState<"register" | "login">("login");
|
||||
const [username, setUsername] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// Check existing session
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.user) setUser(data.user)
|
||||
fetch("/api/auth/me")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.user) setUser(data.user);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setBusy(true)
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setBusy(true);
|
||||
|
||||
try {
|
||||
const optRes = await fetch('/api/auth/register/options', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
const optRes = await fetch("/api/auth/register/options", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: username.trim() }),
|
||||
})
|
||||
});
|
||||
if (!optRes.ok) {
|
||||
const err = await optRes.json()
|
||||
throw new Error(err.error || 'Failed to start registration')
|
||||
const err = await optRes.json();
|
||||
throw new Error(err.error || "Failed to start registration");
|
||||
}
|
||||
const options = await optRes.json()
|
||||
const credential = await startRegistration({ optionsJSON: options })
|
||||
const verRes = await fetch('/api/auth/register/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
const options = await optRes.json();
|
||||
const credential = await startRegistration({ optionsJSON: options });
|
||||
const verRes = await fetch("/api/auth/register/verify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(credential),
|
||||
})
|
||||
});
|
||||
if (!verRes.ok) {
|
||||
const err = await verRes.json()
|
||||
throw new Error(err.error || 'Registration failed')
|
||||
const err = await verRes.json();
|
||||
throw new Error(err.error || "Registration failed");
|
||||
}
|
||||
const result = await verRes.json()
|
||||
setUser({ id: 0, username: result.username })
|
||||
const result = await verRes.json();
|
||||
setUser({ id: 0, username: result.username });
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
if (err.name === 'NotAllowedError') {
|
||||
setError('Passkey creation was cancelled')
|
||||
if (err.name === "NotAllowedError") {
|
||||
setError("Passkey creation was cancelled");
|
||||
} else {
|
||||
setError(err.message)
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setBusy(false)
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
setError('')
|
||||
setBusy(true)
|
||||
setError("");
|
||||
setBusy(true);
|
||||
|
||||
try {
|
||||
const optRes = await fetch('/api/auth/login/options', { method: 'POST' })
|
||||
if (!optRes.ok) throw new Error('Failed to start login')
|
||||
const options = await optRes.json()
|
||||
const credential = await startAuthentication({ optionsJSON: options })
|
||||
const verRes = await fetch('/api/auth/login/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
const optRes = await fetch("/api/auth/login/options", { method: "POST" });
|
||||
if (!optRes.ok) throw new Error("Failed to start login");
|
||||
const options = await optRes.json();
|
||||
const credential = await startAuthentication({ optionsJSON: options });
|
||||
const verRes = await fetch("/api/auth/login/verify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(credential),
|
||||
})
|
||||
});
|
||||
if (!verRes.ok) {
|
||||
const err = await verRes.json()
|
||||
throw new Error(err.error || 'Login failed')
|
||||
const err = await verRes.json();
|
||||
throw new Error(err.error || "Login failed");
|
||||
}
|
||||
const result = await verRes.json()
|
||||
setUser({ id: 0, username: result.username })
|
||||
const result = await verRes.json();
|
||||
setUser({ id: 0, username: result.username });
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
if (err.name === 'NotAllowedError') {
|
||||
setError('Passkey authentication was cancelled')
|
||||
if (err.name === "NotAllowedError") {
|
||||
setError("Passkey authentication was cancelled");
|
||||
} else {
|
||||
setError(err.message)
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setBusy(false)
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST' })
|
||||
setUser(null)
|
||||
}
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -114,58 +117,71 @@ export function AuthGate({ children }: Props) {
|
|||
<div className="auth-title">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (user) {
|
||||
return <>{children(user, handleLogout)}</>
|
||||
return <>{children(user, handleLogout)}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-container">
|
||||
<div className="auth-card">
|
||||
<div className="auth-passkey">⌨️</div>
|
||||
<div className="auth-title">Leo's Typing Tutor</div>
|
||||
<div className="auth-subtitle">Sign in with a passkey to track your progress</div>
|
||||
<div className="auth-title">カンポス家のお宿題</div>
|
||||
<div className="auth-subtitle">
|
||||
Sign in with a passkey to track your progress
|
||||
</div>
|
||||
|
||||
<div className="auth-tabs">
|
||||
<button
|
||||
className={`auth-tab ${tab === 'login' ? 'auth-tabActive' : ''}`}
|
||||
onClick={() => { setTab('login'); setError('') }}
|
||||
className={`auth-tab ${tab === "login" ? "auth-tabActive" : ""}`}
|
||||
onClick={() => {
|
||||
setTab("login");
|
||||
setError("");
|
||||
}}
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
<button
|
||||
className={`auth-tab ${tab === 'register' ? 'auth-tabActive' : ''}`}
|
||||
onClick={() => { setTab('register'); setError('') }}
|
||||
className={`auth-tab ${tab === "register" ? "auth-tabActive" : ""}`}
|
||||
onClick={() => {
|
||||
setTab("register");
|
||||
setError("");
|
||||
}}
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === 'register' ? (
|
||||
{tab === "register" ? (
|
||||
<form className="auth-form" onSubmit={handleRegister}>
|
||||
<input
|
||||
className="auth-input"
|
||||
type="text"
|
||||
placeholder="Pick a username"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
maxLength={32}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<button className="auth-btn" type="submit" disabled={busy || !username.trim()}>
|
||||
{busy ? 'Creating passkey...' : 'Create Account'}
|
||||
<button
|
||||
className="auth-btn"
|
||||
type="submit"
|
||||
disabled={busy || !username.trim()}
|
||||
>
|
||||
{busy ? "Creating passkey..." : "Create Account"}
|
||||
</button>
|
||||
<div className="auth-hint">
|
||||
You'll be asked to create a passkey using your device's fingerprint, face, or PIN.
|
||||
You'll be asked to create a passkey using your device's
|
||||
fingerprint, face, or PIN.
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="auth-form">
|
||||
<button className="auth-btn" onClick={handleLogin} disabled={busy}>
|
||||
{busy ? 'Authenticating...' : 'Sign in with Passkey'}
|
||||
{busy ? "Authenticating..." : "Sign in with Passkey"}
|
||||
</button>
|
||||
<div className="auth-hint">
|
||||
Your device will show your saved passkeys. Pick yours to sign in.
|
||||
|
|
@ -176,5 +192,5 @@ export function AuthGate({ children }: Props) {
|
|||
{error && <div className="auth-error">{error}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,52 +1,63 @@
|
|||
import { type ReactNode } from 'react'
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
type Tab = 'lessons' | 'free' | 'game' | 'stats'
|
||||
type Tab = "lessons" | "free" | "game" | "stats";
|
||||
|
||||
type Props = {
|
||||
activeTab: Tab
|
||||
onTabChange: (tab: Tab) => void
|
||||
username: string
|
||||
onLogout: () => void
|
||||
children: ReactNode
|
||||
}
|
||||
activeTab: Tab;
|
||||
onTabChange: (tab: Tab) => void;
|
||||
username: string;
|
||||
onLogout: () => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const TABS: { id: Tab; label: string; icon: string }[] = [
|
||||
{ id: 'lessons', label: 'Lessons', icon: '📚' },
|
||||
{ id: 'free', label: 'Free Type', icon: '⌨️' },
|
||||
{ id: 'game', label: 'Game', icon: '🎮' },
|
||||
{ id: 'stats', label: 'Stats', icon: '📊' },
|
||||
]
|
||||
{ id: "lessons", label: "Lessons", icon: "📚" },
|
||||
{ id: "free", label: "Free Type", icon: "⌨️" },
|
||||
{ id: "game", label: "Game", icon: "🎮" },
|
||||
{ id: "stats", label: "Stats", icon: "📊" },
|
||||
];
|
||||
|
||||
export function Layout({ activeTab, onTabChange, username, onLogout, children }: Props) {
|
||||
export function Layout({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
username,
|
||||
onLogout,
|
||||
children,
|
||||
}: Props) {
|
||||
return (
|
||||
<div style={{ maxWidth: 960, margin: '0 auto', padding: '20px 24px' }}>
|
||||
<header style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 32,
|
||||
}}>
|
||||
<h1 style={{
|
||||
fontSize: 26,
|
||||
fontWeight: 800,
|
||||
background: 'linear-gradient(135deg, var(--accent), #e84393)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}>
|
||||
Leo's Typing Tutor
|
||||
<div style={{ maxWidth: 960, margin: "0 auto", padding: "20px 24px" }}>
|
||||
<header
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 32,
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: 26,
|
||||
fontWeight: 800,
|
||||
background: "linear-gradient(135deg, var(--accent), #e84393)",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
}}
|
||||
>
|
||||
カンポス家のお宿題
|
||||
</h1>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<nav style={{ display: 'flex', gap: 4 }}>
|
||||
{TABS.map(tab => (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<nav style={{ display: "flex", gap: 4 }}>
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
style={{
|
||||
background: activeTab === tab.id ? 'var(--accent)' : 'var(--bg-card)',
|
||||
color: activeTab === tab.id ? '#fff' : 'var(--text)',
|
||||
background:
|
||||
activeTab === tab.id ? "var(--accent)" : "var(--bg-card)",
|
||||
color: activeTab === tab.id ? "#fff" : "var(--text)",
|
||||
fontWeight: activeTab === tab.id ? 600 : 400,
|
||||
padding: '10px 18px',
|
||||
borderRadius: 'var(--radius)',
|
||||
padding: "10px 18px",
|
||||
borderRadius: "var(--radius)",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
|
|
@ -54,16 +65,25 @@ export function Layout({ activeTab, onTabChange, username, onLogout, children }:
|
|||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 8 }}>
|
||||
<span style={{ color: 'var(--text-dim)', fontSize: 13 }}>{username}</span>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginLeft: 8,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "var(--text-dim)", fontSize: 13 }}>
|
||||
{username}
|
||||
</span>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
style={{
|
||||
background: 'var(--bg-card)',
|
||||
color: 'var(--text-dim)',
|
||||
background: "var(--bg-card)",
|
||||
color: "var(--text-dim)",
|
||||
fontSize: 12,
|
||||
padding: '6px 12px',
|
||||
borderRadius: 'var(--radius)',
|
||||
padding: "6px 12px",
|
||||
borderRadius: "var(--radius)",
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
|
|
@ -73,5 +93,5 @@ export function Layout({ activeTab, onTabChange, username, onLogout, children }:
|
|||
</header>
|
||||
<main>{children}</main>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue