checkpoint
This commit is contained in:
parent
199dab69f9
commit
9439681df9
4 changed files with 296 additions and 227 deletions
20
index.html
20
index.html
|
|
@ -1,16 +1,22 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
|
||||||
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⌨️</text></svg>" />
|
<link rel="icon"
|
||||||
|
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⌨️</text></svg>" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Leo's Typing Tutor</title>
|
<title>カンポス家のお宿題</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet" />
|
<link
|
||||||
</head>
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=JetBrains+Mono:wght@400;600;700&display=swap"
|
||||||
<body>
|
rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
197
server/auth.ts
197
server/auth.ts
|
|
@ -3,12 +3,12 @@ import {
|
||||||
verifyRegistrationResponse,
|
verifyRegistrationResponse,
|
||||||
generateAuthenticationOptions,
|
generateAuthenticationOptions,
|
||||||
verifyAuthenticationResponse,
|
verifyAuthenticationResponse,
|
||||||
} from "@simplewebauthn/server"
|
} from "@simplewebauthn/server";
|
||||||
import { isoBase64URL } from "@simplewebauthn/server/helpers"
|
import { isoBase64URL } from "@simplewebauthn/server/helpers";
|
||||||
import type {
|
import type {
|
||||||
RegistrationResponseJSON,
|
RegistrationResponseJSON,
|
||||||
AuthenticationResponseJSON,
|
AuthenticationResponseJSON,
|
||||||
} from "@simplewebauthn/server"
|
} from "@simplewebauthn/server";
|
||||||
import {
|
import {
|
||||||
createUser,
|
createUser,
|
||||||
getUserByUsername,
|
getUserByUsername,
|
||||||
|
|
@ -19,198 +19,212 @@ import {
|
||||||
createSession,
|
createSession,
|
||||||
getSession,
|
getSession,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
} from "./db"
|
} from "./db";
|
||||||
|
|
||||||
const RP_NAME = "Leo's Typing Tutor"
|
const RP_NAME = "shukudai";
|
||||||
|
|
||||||
// Temporary challenge store (in-memory, keyed by random ID → challenge + metadata)
|
// Temporary challenge store (in-memory, keyed by random ID → challenge + metadata)
|
||||||
const challenges = new Map<string, { challenge: string; userId?: number; expires: number }>()
|
const challenges = new Map<
|
||||||
|
string,
|
||||||
|
{ challenge: string; userId?: number; expires: number }
|
||||||
|
>();
|
||||||
|
|
||||||
// Cleanup expired challenges periodically
|
// Cleanup expired challenges periodically
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const now = Date.now()
|
const now = Date.now();
|
||||||
for (const [key, val] of challenges) {
|
for (const [key, val] of challenges) {
|
||||||
if (val.expires < now) challenges.delete(key)
|
if (val.expires < now) challenges.delete(key);
|
||||||
}
|
}
|
||||||
}, 60_000)
|
}, 60_000);
|
||||||
|
|
||||||
function getRpId(req: Request): string {
|
function getRpId(req: Request): string {
|
||||||
const url = new URL(req.url)
|
const url = new URL(req.url);
|
||||||
return url.hostname
|
return url.hostname;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOrigin(req: Request): string {
|
function getOrigin(req: Request): string {
|
||||||
const url = new URL(req.url)
|
const url = new URL(req.url);
|
||||||
return url.origin
|
return url.origin;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCookie(req: Request, name: string): string | undefined {
|
function getCookie(req: Request, name: string): string | undefined {
|
||||||
const cookies = req.headers.get("cookie")
|
const cookies = req.headers.get("cookie");
|
||||||
if (!cookies) return undefined
|
if (!cookies) return undefined;
|
||||||
const match = cookies.split(";").find(c => c.trim().startsWith(`${name}=`))
|
const match = cookies.split(";").find((c) => c.trim().startsWith(`${name}=`));
|
||||||
return match?.split("=").slice(1).join("=").trim()
|
return match?.split("=").slice(1).join("=").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function sessionCookie(token: string, maxAge = 7 * 24 * 60 * 60): string {
|
function sessionCookie(token: string, maxAge = 7 * 24 * 60 * 60): string {
|
||||||
return `session=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}`
|
return `session=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function challengeCookie(key: string): string {
|
function challengeCookie(key: string): string {
|
||||||
return `challenge_key=${key}; HttpOnly; SameSite=Strict; Path=/; Max-Age=120`
|
return `challenge_key=${key}; HttpOnly; SameSite=Strict; Path=/; Max-Age=120`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearChallengeCookie(): string {
|
function clearChallengeCookie(): string {
|
||||||
return `challenge_key=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`
|
return `challenge_key=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Route handlers ----
|
// ---- Route handlers ----
|
||||||
|
|
||||||
export async function registerOptions(req: Request): Promise<Response> {
|
export async function registerOptions(req: Request): Promise<Response> {
|
||||||
const { username } = await req.json() as { username: string }
|
const { username } = (await req.json()) as { username: string };
|
||||||
if (!username || username.length < 1 || username.length > 32) {
|
if (!username || username.length < 1 || username.length > 32) {
|
||||||
return Response.json({ error: "Username must be 1-32 characters" }, { status: 400 })
|
return Response.json(
|
||||||
|
{ error: "Username must be 1-32 characters" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if username already taken
|
// Check if username already taken
|
||||||
const existing = await getUserByUsername(username)
|
const existing = await getUserByUsername(username);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return Response.json({ error: "Username already taken" }, { status: 409 })
|
return Response.json({ error: "Username already taken" }, { status: 409 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create user
|
// Create user
|
||||||
const user = await createUser(username)
|
const user = await createUser(username);
|
||||||
|
|
||||||
const rpID = getRpId(req)
|
const rpID = getRpId(req);
|
||||||
const options = await generateRegistrationOptions({
|
const options = await generateRegistrationOptions({
|
||||||
rpName: RP_NAME,
|
rpName: RP_NAME,
|
||||||
rpID,
|
rpID,
|
||||||
userName: username,
|
userName: username,
|
||||||
userID: isoBase64URL.toBuffer(isoBase64URL.fromUTF8String(user.id.toString())),
|
userID: isoBase64URL.toBuffer(
|
||||||
|
isoBase64URL.fromUTF8String(user.id.toString()),
|
||||||
|
),
|
||||||
attestationType: "none",
|
attestationType: "none",
|
||||||
authenticatorSelection: {
|
authenticatorSelection: {
|
||||||
residentKey: "required",
|
residentKey: "required",
|
||||||
userVerification: "preferred",
|
userVerification: "preferred",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
// Store challenge
|
// Store challenge
|
||||||
const challengeKey = crypto.randomUUID()
|
const challengeKey = crypto.randomUUID();
|
||||||
challenges.set(challengeKey, {
|
challenges.set(challengeKey, {
|
||||||
challenge: options.challenge,
|
challenge: options.challenge,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
expires: Date.now() + 120_000,
|
expires: Date.now() + 120_000,
|
||||||
})
|
});
|
||||||
|
|
||||||
return new Response(JSON.stringify(options), {
|
return new Response(JSON.stringify(options), {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Set-Cookie": challengeCookie(challengeKey),
|
"Set-Cookie": challengeCookie(challengeKey),
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerVerify(req: Request): Promise<Response> {
|
export async function registerVerify(req: Request): Promise<Response> {
|
||||||
const body = (await req.json()) as RegistrationResponseJSON
|
const body = (await req.json()) as RegistrationResponseJSON;
|
||||||
const challengeKey = getCookie(req, "challenge_key")
|
const challengeKey = getCookie(req, "challenge_key");
|
||||||
if (!challengeKey) {
|
if (!challengeKey) {
|
||||||
return Response.json({ error: "No challenge found" }, { status: 400 })
|
return Response.json({ error: "No challenge found" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const stored = challenges.get(challengeKey)
|
const stored = challenges.get(challengeKey);
|
||||||
if (!stored || stored.expires < Date.now()) {
|
if (!stored || stored.expires < Date.now()) {
|
||||||
challenges.delete(challengeKey!)
|
challenges.delete(challengeKey!);
|
||||||
return Response.json({ error: "Challenge expired" }, { status: 400 })
|
return Response.json({ error: "Challenge expired" }, { status: 400 });
|
||||||
}
|
}
|
||||||
challenges.delete(challengeKey)
|
challenges.delete(challengeKey);
|
||||||
|
|
||||||
const rpID = getRpId(req)
|
const rpID = getRpId(req);
|
||||||
const origin = getOrigin(req)
|
const origin = getOrigin(req);
|
||||||
|
|
||||||
let verification
|
let verification;
|
||||||
try {
|
try {
|
||||||
verification = await verifyRegistrationResponse({
|
verification = await verifyRegistrationResponse({
|
||||||
response: body,
|
response: body,
|
||||||
expectedChallenge: stored.challenge,
|
expectedChallenge: stored.challenge,
|
||||||
expectedOrigin: origin,
|
expectedOrigin: origin,
|
||||||
expectedRPID: rpID,
|
expectedRPID: rpID,
|
||||||
})
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return Response.json({ error: `Verification failed: ${err}` }, { status: 400 })
|
return Response.json(
|
||||||
|
{ error: `Verification failed: ${err}` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!verification.verified || !verification.registrationInfo) {
|
if (!verification.verified || !verification.registrationInfo) {
|
||||||
return Response.json({ error: "Verification failed" }, { status: 400 })
|
return Response.json({ error: "Verification failed" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { credential } = verification.registrationInfo
|
const { credential } = verification.registrationInfo;
|
||||||
await storeCredential(
|
await storeCredential(
|
||||||
credential.id,
|
credential.id,
|
||||||
stored.userId!,
|
stored.userId!,
|
||||||
isoBase64URL.fromBuffer(credential.publicKey),
|
isoBase64URL.fromBuffer(credential.publicKey),
|
||||||
credential.counter,
|
credential.counter,
|
||||||
credential.transports,
|
credential.transports,
|
||||||
)
|
);
|
||||||
|
|
||||||
// Create session
|
// Create session
|
||||||
const token = await createSession(stored.userId!)
|
const token = await createSession(stored.userId!);
|
||||||
const user = await getUserById(stored.userId!)
|
const user = await getUserById(stored.userId!);
|
||||||
|
|
||||||
return new Response(JSON.stringify({ verified: true, username: user?.username }), {
|
return new Response(
|
||||||
|
JSON.stringify({ verified: true, username: user?.username }),
|
||||||
|
{
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Set-Cookie": [sessionCookie(token), clearChallengeCookie()].join(", "),
|
"Set-Cookie": [sessionCookie(token), clearChallengeCookie()].join(", "),
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginOptions(req: Request): Promise<Response> {
|
export async function loginOptions(req: Request): Promise<Response> {
|
||||||
const rpID = getRpId(req)
|
const rpID = getRpId(req);
|
||||||
|
|
||||||
const options = await generateAuthenticationOptions({
|
const options = await generateAuthenticationOptions({
|
||||||
rpID,
|
rpID,
|
||||||
userVerification: "preferred",
|
userVerification: "preferred",
|
||||||
// Empty allowCredentials = discoverable credentials (passkey prompt)
|
// Empty allowCredentials = discoverable credentials (passkey prompt)
|
||||||
})
|
});
|
||||||
|
|
||||||
const challengeKey = crypto.randomUUID()
|
const challengeKey = crypto.randomUUID();
|
||||||
challenges.set(challengeKey, {
|
challenges.set(challengeKey, {
|
||||||
challenge: options.challenge,
|
challenge: options.challenge,
|
||||||
expires: Date.now() + 120_000,
|
expires: Date.now() + 120_000,
|
||||||
})
|
});
|
||||||
|
|
||||||
return new Response(JSON.stringify(options), {
|
return new Response(JSON.stringify(options), {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Set-Cookie": challengeCookie(challengeKey),
|
"Set-Cookie": challengeCookie(challengeKey),
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginVerify(req: Request): Promise<Response> {
|
export async function loginVerify(req: Request): Promise<Response> {
|
||||||
const body = (await req.json()) as AuthenticationResponseJSON
|
const body = (await req.json()) as AuthenticationResponseJSON;
|
||||||
const challengeKey = getCookie(req, "challenge_key")
|
const challengeKey = getCookie(req, "challenge_key");
|
||||||
if (!challengeKey) {
|
if (!challengeKey) {
|
||||||
return Response.json({ error: "No challenge found" }, { status: 400 })
|
return Response.json({ error: "No challenge found" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const stored = challenges.get(challengeKey)
|
const stored = challenges.get(challengeKey);
|
||||||
if (!stored || stored.expires < Date.now()) {
|
if (!stored || stored.expires < Date.now()) {
|
||||||
challenges.delete(challengeKey!)
|
challenges.delete(challengeKey!);
|
||||||
return Response.json({ error: "Challenge expired" }, { status: 400 })
|
return Response.json({ error: "Challenge expired" }, { status: 400 });
|
||||||
}
|
}
|
||||||
challenges.delete(challengeKey)
|
challenges.delete(challengeKey);
|
||||||
|
|
||||||
// Look up credential
|
// Look up credential
|
||||||
const credentialId = body.id
|
const credentialId = body.id;
|
||||||
const credential = await getCredentialById(credentialId)
|
const credential = await getCredentialById(credentialId);
|
||||||
if (!credential) {
|
if (!credential) {
|
||||||
return Response.json({ error: "Unknown credential" }, { status: 400 })
|
return Response.json({ error: "Unknown credential" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const rpID = getRpId(req)
|
const rpID = getRpId(req);
|
||||||
const origin = getOrigin(req)
|
const origin = getOrigin(req);
|
||||||
|
|
||||||
let verification
|
let verification;
|
||||||
try {
|
try {
|
||||||
verification = await verifyAuthenticationResponse({
|
verification = await verifyAuthenticationResponse({
|
||||||
response: body,
|
response: body,
|
||||||
|
|
@ -221,60 +235,73 @@ export async function loginVerify(req: Request): Promise<Response> {
|
||||||
id: credential.id,
|
id: credential.id,
|
||||||
publicKey: isoBase64URL.toBuffer(credential.public_key),
|
publicKey: isoBase64URL.toBuffer(credential.public_key),
|
||||||
counter: credential.counter,
|
counter: credential.counter,
|
||||||
transports: credential.transports ? JSON.parse(credential.transports) : undefined,
|
transports: credential.transports
|
||||||
|
? JSON.parse(credential.transports)
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return Response.json({ error: `Verification failed: ${err}` }, { status: 400 })
|
return Response.json(
|
||||||
|
{ error: `Verification failed: ${err}` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!verification.verified) {
|
if (!verification.verified) {
|
||||||
return Response.json({ error: "Verification failed" }, { status: 400 })
|
return Response.json({ error: "Verification failed" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update counter
|
// Update counter
|
||||||
await updateCredentialCounter(credentialId, verification.authenticationInfo.newCounter)
|
await updateCredentialCounter(
|
||||||
|
credentialId,
|
||||||
|
verification.authenticationInfo.newCounter,
|
||||||
|
);
|
||||||
|
|
||||||
// Create session
|
// Create session
|
||||||
const token = await createSession(credential.user_id)
|
const token = await createSession(credential.user_id);
|
||||||
const user = await getUserById(credential.user_id)
|
const user = await getUserById(credential.user_id);
|
||||||
|
|
||||||
return new Response(JSON.stringify({ verified: true, username: user?.username }), {
|
return new Response(
|
||||||
|
JSON.stringify({ verified: true, username: user?.username }),
|
||||||
|
{
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Set-Cookie": [sessionCookie(token), clearChallengeCookie()].join(", "),
|
"Set-Cookie": [sessionCookie(token), clearChallengeCookie()].join(", "),
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function me(req: Request): Promise<Response> {
|
export async function me(req: Request): Promise<Response> {
|
||||||
const token = getCookie(req, "session")
|
const token = getCookie(req, "session");
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return Response.json({ user: null })
|
return Response.json({ user: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await getSession(token)
|
const session = await getSession(token);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return new Response(JSON.stringify({ user: null }), {
|
return new Response(JSON.stringify({ user: null }), {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Set-Cookie": sessionCookie("", 0), // clear expired cookie
|
"Set-Cookie": sessionCookie("", 0), // clear expired cookie
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response.json({ user: { id: session.user_id, username: session.username } })
|
return Response.json({
|
||||||
|
user: { id: session.user_id, username: session.username },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logout(req: Request): Promise<Response> {
|
export async function logout(req: Request): Promise<Response> {
|
||||||
const token = getCookie(req, "session")
|
const token = getCookie(req, "session");
|
||||||
if (token) {
|
if (token) {
|
||||||
await deleteSession(token)
|
await deleteSession(token);
|
||||||
}
|
}
|
||||||
return new Response(JSON.stringify({ ok: true }), {
|
return new Response(JSON.stringify({ ok: true }), {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Set-Cookie": sessionCookie("", 0),
|
"Set-Cookie": sessionCookie("", 0),
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,110 +1,113 @@
|
||||||
import { useState, useEffect, type ReactNode } from 'react'
|
import { useState, useEffect, type ReactNode } from "react";
|
||||||
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
|
import {
|
||||||
import '../styles/auth.css'
|
startRegistration,
|
||||||
|
startAuthentication,
|
||||||
|
} from "@simplewebauthn/browser";
|
||||||
|
import "../styles/auth.css";
|
||||||
|
|
||||||
type User = { id: number; username: string }
|
type User = { id: number; username: string };
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: (user: User, onLogout: () => void) => ReactNode
|
children: (user: User, onLogout: () => void) => ReactNode;
|
||||||
}
|
};
|
||||||
|
|
||||||
export function AuthGate({ children }: Props) {
|
export function AuthGate({ children }: Props) {
|
||||||
const [user, setUser] = useState<User | null>(null)
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true);
|
||||||
const [tab, setTab] = useState<'register' | 'login'>('login')
|
const [tab, setTab] = useState<"register" | "login">("login");
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState("");
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState("");
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
// Check existing session
|
// Check existing session
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/auth/me')
|
fetch("/api/auth/me")
|
||||||
.then(r => r.json())
|
.then((r) => r.json())
|
||||||
.then(data => {
|
.then((data) => {
|
||||||
if (data.user) setUser(data.user)
|
if (data.user) setUser(data.user);
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false));
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const handleRegister = async (e: React.FormEvent) => {
|
const handleRegister = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
setError('')
|
setError("");
|
||||||
setBusy(true)
|
setBusy(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const optRes = await fetch('/api/auth/register/options', {
|
const optRes = await fetch("/api/auth/register/options", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ username: username.trim() }),
|
body: JSON.stringify({ username: username.trim() }),
|
||||||
})
|
});
|
||||||
if (!optRes.ok) {
|
if (!optRes.ok) {
|
||||||
const err = await optRes.json()
|
const err = await optRes.json();
|
||||||
throw new Error(err.error || 'Failed to start registration')
|
throw new Error(err.error || "Failed to start registration");
|
||||||
}
|
}
|
||||||
const options = await optRes.json()
|
const options = await optRes.json();
|
||||||
const credential = await startRegistration({ optionsJSON: options })
|
const credential = await startRegistration({ optionsJSON: options });
|
||||||
const verRes = await fetch('/api/auth/register/verify', {
|
const verRes = await fetch("/api/auth/register/verify", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(credential),
|
body: JSON.stringify(credential),
|
||||||
})
|
});
|
||||||
if (!verRes.ok) {
|
if (!verRes.ok) {
|
||||||
const err = await verRes.json()
|
const err = await verRes.json();
|
||||||
throw new Error(err.error || 'Registration failed')
|
throw new Error(err.error || "Registration failed");
|
||||||
}
|
}
|
||||||
const result = await verRes.json()
|
const result = await verRes.json();
|
||||||
setUser({ id: 0, username: result.username })
|
setUser({ id: 0, username: result.username });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
if (err.name === 'NotAllowedError') {
|
if (err.name === "NotAllowedError") {
|
||||||
setError('Passkey creation was cancelled')
|
setError("Passkey creation was cancelled");
|
||||||
} else {
|
} else {
|
||||||
setError(err.message)
|
setError(err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
setError('')
|
setError("");
|
||||||
setBusy(true)
|
setBusy(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const optRes = await fetch('/api/auth/login/options', { method: 'POST' })
|
const optRes = await fetch("/api/auth/login/options", { method: "POST" });
|
||||||
if (!optRes.ok) throw new Error('Failed to start login')
|
if (!optRes.ok) throw new Error("Failed to start login");
|
||||||
const options = await optRes.json()
|
const options = await optRes.json();
|
||||||
const credential = await startAuthentication({ optionsJSON: options })
|
const credential = await startAuthentication({ optionsJSON: options });
|
||||||
const verRes = await fetch('/api/auth/login/verify', {
|
const verRes = await fetch("/api/auth/login/verify", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(credential),
|
body: JSON.stringify(credential),
|
||||||
})
|
});
|
||||||
if (!verRes.ok) {
|
if (!verRes.ok) {
|
||||||
const err = await verRes.json()
|
const err = await verRes.json();
|
||||||
throw new Error(err.error || 'Login failed')
|
throw new Error(err.error || "Login failed");
|
||||||
}
|
}
|
||||||
const result = await verRes.json()
|
const result = await verRes.json();
|
||||||
setUser({ id: 0, username: result.username })
|
setUser({ id: 0, username: result.username });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
if (err.name === 'NotAllowedError') {
|
if (err.name === "NotAllowedError") {
|
||||||
setError('Passkey authentication was cancelled')
|
setError("Passkey authentication was cancelled");
|
||||||
} else {
|
} else {
|
||||||
setError(err.message)
|
setError(err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await fetch('/api/auth/logout', { method: 'POST' })
|
await fetch("/api/auth/logout", { method: "POST" });
|
||||||
setUser(null)
|
setUser(null);
|
||||||
}
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -114,58 +117,71 @@ export function AuthGate({ children }: Props) {
|
||||||
<div className="auth-title">Loading...</div>
|
<div className="auth-title">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
return <>{children(user, handleLogout)}</>
|
return <>{children(user, handleLogout)}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="auth-container">
|
<div className="auth-container">
|
||||||
<div className="auth-card">
|
<div className="auth-card">
|
||||||
<div className="auth-passkey">⌨️</div>
|
<div className="auth-passkey">⌨️</div>
|
||||||
<div className="auth-title">Leo's Typing Tutor</div>
|
<div className="auth-title">カンポス家のお宿題</div>
|
||||||
<div className="auth-subtitle">Sign in with a passkey to track your progress</div>
|
<div className="auth-subtitle">
|
||||||
|
Sign in with a passkey to track your progress
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="auth-tabs">
|
<div className="auth-tabs">
|
||||||
<button
|
<button
|
||||||
className={`auth-tab ${tab === 'login' ? 'auth-tabActive' : ''}`}
|
className={`auth-tab ${tab === "login" ? "auth-tabActive" : ""}`}
|
||||||
onClick={() => { setTab('login'); setError('') }}
|
onClick={() => {
|
||||||
|
setTab("login");
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Login
|
Login
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`auth-tab ${tab === 'register' ? 'auth-tabActive' : ''}`}
|
className={`auth-tab ${tab === "register" ? "auth-tabActive" : ""}`}
|
||||||
onClick={() => { setTab('register'); setError('') }}
|
onClick={() => {
|
||||||
|
setTab("register");
|
||||||
|
setError("");
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Register
|
Register
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === 'register' ? (
|
{tab === "register" ? (
|
||||||
<form className="auth-form" onSubmit={handleRegister}>
|
<form className="auth-form" onSubmit={handleRegister}>
|
||||||
<input
|
<input
|
||||||
className="auth-input"
|
className="auth-input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Pick a username"
|
placeholder="Pick a username"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={e => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
maxLength={32}
|
maxLength={32}
|
||||||
autoFocus
|
autoFocus
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<button className="auth-btn" type="submit" disabled={busy || !username.trim()}>
|
<button
|
||||||
{busy ? 'Creating passkey...' : 'Create Account'}
|
className="auth-btn"
|
||||||
|
type="submit"
|
||||||
|
disabled={busy || !username.trim()}
|
||||||
|
>
|
||||||
|
{busy ? "Creating passkey..." : "Create Account"}
|
||||||
</button>
|
</button>
|
||||||
<div className="auth-hint">
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<div className="auth-form">
|
<div className="auth-form">
|
||||||
<button className="auth-btn" onClick={handleLogin} disabled={busy}>
|
<button className="auth-btn" onClick={handleLogin} disabled={busy}>
|
||||||
{busy ? 'Authenticating...' : 'Sign in with Passkey'}
|
{busy ? "Authenticating..." : "Sign in with Passkey"}
|
||||||
</button>
|
</button>
|
||||||
<div className="auth-hint">
|
<div className="auth-hint">
|
||||||
Your device will show your saved passkeys. Pick yours to sign in.
|
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>}
|
{error && <div className="auth-error">{error}</div>}
|
||||||
</div>
|
</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 = {
|
type Props = {
|
||||||
activeTab: Tab
|
activeTab: Tab;
|
||||||
onTabChange: (tab: Tab) => void
|
onTabChange: (tab: Tab) => void;
|
||||||
username: string
|
username: string;
|
||||||
onLogout: () => void
|
onLogout: () => void;
|
||||||
children: ReactNode
|
children: ReactNode;
|
||||||
}
|
};
|
||||||
|
|
||||||
const TABS: { id: Tab; label: string; icon: string }[] = [
|
const TABS: { id: Tab; label: string; icon: string }[] = [
|
||||||
{ id: 'lessons', label: 'Lessons', icon: '📚' },
|
{ id: "lessons", label: "Lessons", icon: "📚" },
|
||||||
{ id: 'free', label: 'Free Type', icon: '⌨️' },
|
{ id: "free", label: "Free Type", icon: "⌨️" },
|
||||||
{ id: 'game', label: 'Game', icon: '🎮' },
|
{ id: "game", label: "Game", icon: "🎮" },
|
||||||
{ id: 'stats', label: 'Stats', 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 (
|
return (
|
||||||
<div style={{ maxWidth: 960, margin: '0 auto', padding: '20px 24px' }}>
|
<div style={{ maxWidth: 960, margin: "0 auto", padding: "20px 24px" }}>
|
||||||
<header style={{
|
<header
|
||||||
display: 'flex',
|
style={{
|
||||||
alignItems: 'center',
|
display: "flex",
|
||||||
justifyContent: 'space-between',
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
marginBottom: 32,
|
marginBottom: 32,
|
||||||
}}>
|
}}
|
||||||
<h1 style={{
|
>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
fontSize: 26,
|
fontSize: 26,
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
background: 'linear-gradient(135deg, var(--accent), #e84393)',
|
background: "linear-gradient(135deg, var(--accent), #e84393)",
|
||||||
WebkitBackgroundClip: 'text',
|
WebkitBackgroundClip: "text",
|
||||||
WebkitTextFillColor: 'transparent',
|
WebkitTextFillColor: "transparent",
|
||||||
}}>
|
}}
|
||||||
Leo's Typing Tutor
|
>
|
||||||
|
カンポス家のお宿題
|
||||||
</h1>
|
</h1>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
<nav style={{ display: 'flex', gap: 4 }}>
|
<nav style={{ display: "flex", gap: 4 }}>
|
||||||
{TABS.map(tab => (
|
{TABS.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => onTabChange(tab.id)}
|
onClick={() => onTabChange(tab.id)}
|
||||||
style={{
|
style={{
|
||||||
background: activeTab === tab.id ? 'var(--accent)' : 'var(--bg-card)',
|
background:
|
||||||
color: activeTab === tab.id ? '#fff' : 'var(--text)',
|
activeTab === tab.id ? "var(--accent)" : "var(--bg-card)",
|
||||||
|
color: activeTab === tab.id ? "#fff" : "var(--text)",
|
||||||
fontWeight: activeTab === tab.id ? 600 : 400,
|
fontWeight: activeTab === tab.id ? 600 : 400,
|
||||||
padding: '10px 18px',
|
padding: "10px 18px",
|
||||||
borderRadius: 'var(--radius)',
|
borderRadius: "var(--radius)",
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -54,16 +65,25 @@ export function Layout({ activeTab, onTabChange, username, onLogout, children }:
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 8 }}>
|
<div
|
||||||
<span style={{ color: 'var(--text-dim)', fontSize: 13 }}>{username}</span>
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
marginLeft: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: "var(--text-dim)", fontSize: 13 }}>
|
||||||
|
{username}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={onLogout}
|
onClick={onLogout}
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--bg-card)',
|
background: "var(--bg-card)",
|
||||||
color: 'var(--text-dim)',
|
color: "var(--text-dim)",
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
padding: '6px 12px',
|
padding: "6px 12px",
|
||||||
borderRadius: 'var(--radius)',
|
borderRadius: "var(--radius)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Logout
|
Logout
|
||||||
|
|
@ -73,5 +93,5 @@ export function Layout({ activeTab, onTabChange, username, onLogout, children }:
|
||||||
</header>
|
</header>
|
||||||
<main>{children}</main>
|
<main>{children}</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue