import { useState, useEffect, useCallback } from "react"; import { data, redirect, useFetcher } from "react-router"; import { useTranslation } from "react-i18next"; import { ClientDate } from "~/components/ClientDate"; import { eq } from "drizzle-orm"; import type { Route } from "./+types/settings"; import { getSessionUser } from "~/lib/auth.server"; import { getDb } from "~/lib/db"; import { credentials, syncConnections } from "@trails-cool/db/schema/journal"; import { getAllProviders } from "~/lib/sync/registry"; export function meta() { return [{ title: "Settings — trails.cool" }]; } export async function loader({ request }: Route.LoaderArgs) { const user = await getSessionUser(request); if (!user) throw redirect("/auth/login"); const db = getDb(); const passkeys = await db .select({ id: credentials.id, deviceType: credentials.deviceType, transports: credentials.transports, createdAt: credentials.createdAt, }) .from(credentials) .where(eq(credentials.userId, user.id)); const connections = await db .select({ provider: syncConnections.provider, providerUserId: syncConnections.providerUserId, }) .from(syncConnections) .where(eq(syncConnections.userId, user.id)); const providers = getAllProviders().map((p) => { const conn = connections.find((c) => c.provider === p.id); return { id: p.id, name: p.name, connected: !!conn, providerUserId: conn?.providerUserId }; }); return data({ user: { id: user.id, username: user.username, email: user.email, displayName: user.displayName, bio: user.bio, profileVisibility: user.profileVisibility, }, passkeys: passkeys.map((p) => ({ id: p.id, deviceType: p.deviceType, transports: p.transports as string[] | null, createdAt: p.createdAt.toISOString(), })), providers, }); } function transportLabel(transports: string[] | null, t: (key: string) => string): string { if (!transports || transports.length === 0) return t("settings.security.passkey"); if (transports.includes("internal")) return t("settings.security.thisDevice"); if (transports.includes("usb")) return t("settings.security.securityKey"); if (transports.includes("ble")) return t("settings.security.bluetooth"); if (transports.includes("hybrid")) return t("settings.security.phoneOrTablet"); return t("settings.security.passkey"); } export default function Settings({ loaderData }: Route.ComponentProps) { const { user, passkeys, providers } = loaderData; const { t } = useTranslation(["journal", "common"]); const profileFetcher = useFetcher(); const emailFetcher = useFetcher(); const deleteFetcher = useFetcher(); const [displayName, setDisplayName] = useState(user.displayName ?? ""); const [bio, setBio] = useState(user.bio ?? ""); const [profileVisibility, setProfileVisibility] = useState<"public" | "private">( user.profileVisibility, ); const [profileSaved, setProfileSaved] = useState(false); const [newEmail, setNewEmail] = useState(""); const [showEmailForm, setShowEmailForm] = useState(false); const [emailSent, setEmailSent] = useState(false); const [supportsPasskey, setSupportsPasskey] = useState(null); const [addingPasskey, setAddingPasskey] = useState(false); const [passkeyError, setPasskeyError] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState(false); const [deleteUsername, setDeleteUsername] = useState(""); useEffect(() => { import("@simplewebauthn/browser").then(({ browserSupportsWebAuthn }) => { setSupportsPasskey(browserSupportsWebAuthn()); }); }, []); useEffect(() => { if (profileFetcher.data && !profileFetcher.data.error) { setProfileSaved(true); const timer = setTimeout(() => setProfileSaved(false), 3000); return () => clearTimeout(timer); } }, [profileFetcher.data]); useEffect(() => { if (emailFetcher.data && !emailFetcher.data.error) { setEmailSent(true); } }, [emailFetcher.data]); const handleAddPasskey = useCallback(async () => { setAddingPasskey(true); setPasskeyError(null); try { const startResp = await fetch("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ step: "add-passkey", userId: user.id }), }); const startData = await startResp.json(); if (startData.error) { setPasskeyError(startData.error); return; } const { startRegistration } = await import("@simplewebauthn/browser"); const webAuthnResp = await startRegistration(startData.options); const finishResp = await fetch("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ step: "finish-add-passkey", userId: user.id, response: webAuthnResp, challenge: startData.options.challenge, }), }); const finishData = await finishResp.json(); if (finishData.error) { setPasskeyError(finishData.error); } else { window.location.reload(); } } catch (err) { setPasskeyError((err as Error).message); } finally { setAddingPasskey(false); } }, [user.id]); return (

{t("settings.title")}

{/* Profile Section */}

{t("settings.profile.title")}

setDisplayName(e.target.value)} placeholder={user.username} className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" />