diff --git a/src/components/layout/navbar.tsx b/src/components/layout/navbar.tsx
index d619e24f..5f84e4aa 100644
--- a/src/components/layout/navbar.tsx
+++ b/src/components/layout/navbar.tsx
@@ -202,6 +202,8 @@ const Navbar: React.FC = () => {
className="account-switcher__trigger"
onClick={() => setSwitcherOpen(!switcherOpen)}
aria-label="Switch account"
+ aria-haspopup="menu"
+ aria-expanded={switcherOpen}
>
@@ -368,6 +370,8 @@ const Navbar: React.FC = () => {
className="navbar__hamburger"
onClick={() => { setDropdownOpen(!dropdownOpen); setSwitcherOpen(false); }}
aria-label={dropdownOpen ? "Close menu" : "Open menu"}
+ aria-haspopup="menu"
+ aria-expanded={dropdownOpen}
>
{dropdownOpen ?
:
}
diff --git a/src/components/ui/feedback-modal.tsx b/src/components/ui/feedback-modal.tsx
index 77d232e8..715e66dd 100644
--- a/src/components/ui/feedback-modal.tsx
+++ b/src/components/ui/feedback-modal.tsx
@@ -3,6 +3,7 @@
import React, { useEffect, useRef, useState, useCallback } from "react"
import { createPortal } from "react-dom"
import { MessageSquare, X, Maximize2, Minimize2 } from "lucide-react"
+import { useFocusTrap } from "@/hooks/use-focus-trap"
export default function FeedbackModal() {
const [isOpen, setIsOpen] = useState(false)
@@ -15,6 +16,7 @@ export default function FeedbackModal() {
const [error, setError] = useState("")
const [bottomOffset, setBottomOffset] = useState(20)
const backdropRef = useRef
(null)
+ const focusTrapRef = useFocusTrap(isOpen)
const textareaRef = useRef(null)
// Bottom sheet drag state (mobile)
@@ -28,7 +30,8 @@ export default function FeedbackModal() {
if (!footer) { setBottomOffset(20); return }
const footerRect = footer.getBoundingClientRect()
const viewportHeight = window.innerHeight
- if (footerRect.top < viewportHeight) {
+ // Only push button up when footer is visible from below, not when scrolled past
+ if (footerRect.top < viewportHeight && footerRect.bottom > 0) {
setBottomOffset(viewportHeight - footerRect.top + 12)
} else {
setBottomOffset(20)
@@ -273,6 +276,7 @@ export default function FeedbackModal() {
>
{
@@ -8,26 +8,35 @@ export interface InputProps
}
const Input = React.forwardRef
(
- ({ label, error, helperText, className = "", ...props }, ref) => {
+ ({ label, error, helperText, className = "", id, ...props }, ref) => {
+ const autoId = useId();
+ const inputId = id || autoId;
+ const errorId = error ? `${inputId}-error` : undefined;
+ const helperId = !error && helperText ? `${inputId}-helper` : undefined;
+ const describedBy = [errorId, helperId].filter(Boolean).join(" ") || undefined;
+
return (
{label && (
-
);
diff --git a/src/components/ui/sign-in-modal.tsx b/src/components/ui/sign-in-modal.tsx
index c1e0fc93..d41d88b0 100644
--- a/src/components/ui/sign-in-modal.tsx
+++ b/src/components/ui/sign-in-modal.tsx
@@ -1,6 +1,7 @@
"use client"
import React, { useEffect, useRef, useState } from "react"
+import { useFocusTrap } from "@/hooks/use-focus-trap"
interface SignInModalProps {
isOpen: boolean
@@ -20,6 +21,7 @@ export default function SignInModal({
onSubmitHandle,
}: SignInModalProps) {
const backdropRef = useRef(null)
+ const focusTrapRef = useFocusTrap(isOpen)
const inputRef = useRef(null)
const [view, setView] = useState("certified")
const [inputValue, setInputValue] = useState("")
@@ -102,7 +104,7 @@ export default function SignInModal({
aria-modal="true"
aria-label={title}
>
-
+
{title}
diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx
index c806768f..414a8f12 100644
--- a/src/components/ui/textarea.tsx
+++ b/src/components/ui/textarea.tsx
@@ -1,4 +1,4 @@
-import React from "react";
+import React, { useId } from "react";
export interface TextareaProps
extends React.TextareaHTMLAttributes
{
@@ -9,27 +9,36 @@ export interface TextareaProps
}
const Textarea = React.forwardRef(
- ({ label, error, helperText, rows = 3, className = "", ...props }, ref) => {
+ ({ label, error, helperText, rows = 3, className = "", id, ...props }, ref) => {
+ const autoId = useId();
+ const textareaId = id || autoId;
+ const errorId = error ? `${textareaId}-error` : undefined;
+ const helperId = !error && helperText ? `${textareaId}-helper` : undefined;
+ const describedBy = [errorId, helperId].filter(Boolean).join(" ") || undefined;
+
return (
{label && (
-
+
{label}
)}
{error && (
- {error}
+ {error}
)}
{!error && helperText && (
- {helperText}
+ {helperText}
)}
);
diff --git a/src/hooks/use-focus-trap.ts b/src/hooks/use-focus-trap.ts
new file mode 100644
index 00000000..4a7dc790
--- /dev/null
+++ b/src/hooks/use-focus-trap.ts
@@ -0,0 +1,41 @@
+import { useEffect, useRef } from "react";
+
+const FOCUSABLE_SELECTOR =
+ 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
+
+/**
+ * Traps keyboard focus within a container element while active.
+ * Returns a ref to attach to the container element.
+ */
+export function useFocusTrap(active: boolean) {
+ const containerRef = useRef(null);
+
+ useEffect(() => {
+ if (!active) return;
+ const container = containerRef.current;
+ if (!container) return;
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key !== "Tab") return;
+
+ const focusable = container.querySelectorAll(FOCUSABLE_SELECTOR);
+ if (focusable.length === 0) return;
+
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+
+ if (e.shiftKey && document.activeElement === first) {
+ e.preventDefault();
+ last.focus();
+ } else if (!e.shiftKey && document.activeElement === last) {
+ e.preventDefault();
+ first.focus();
+ }
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+ return () => document.removeEventListener("keydown", handleKeyDown);
+ }, [active]);
+
+ return containerRef;
+}
diff --git a/src/lib/auth/csrf.ts b/src/lib/auth/csrf.ts
index bf41e78e..48c63cdd 100644
--- a/src/lib/auth/csrf.ts
+++ b/src/lib/auth/csrf.ts
@@ -12,11 +12,15 @@ export function checkCsrf(request: NextRequest): NextResponse | null {
// Browsers always send Origin on cross-origin POST requests.
if (!origin) return null
- const expectedOrigin = new URL(PUBLIC_URL).origin
- const requestOrigin = new URL(origin).origin
+ try {
+ const expectedOrigin = new URL(PUBLIC_URL).origin
+ const requestOrigin = new URL(origin).origin
- if (requestOrigin !== expectedOrigin) {
+ if (requestOrigin !== expectedOrigin) {
+ return NextResponse.json({ error: "Forbidden: invalid origin" }, { status: 403 })
+ }
+ return null
+ } catch {
return NextResponse.json({ error: "Forbidden: invalid origin" }, { status: 403 })
}
- return null
}
diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts
index 910576d9..ddbe11b8 100644
--- a/src/lib/auth/session.ts
+++ b/src/lib/auth/session.ts
@@ -19,20 +19,19 @@ function sign(sessionId: string): string {
}
export async function createSession(did: string): Promise {
- // Note: we don't scan/invalidate old sessions for this DID in Redis
- // because scanning is expensive. The old session cookie on the client
- // will simply be overwritten, and the orphaned Redis key will expire
- // via TTL.
-
const sessionId = randomBytes(32).toString("hex")
const signature = sign(sessionId)
const cookieValue = `${sessionId}.${signature}`
- // Store session-to-DID mapping in Redis
- const redis = getRedis()
- await redis.set(`${SESSION_DID_PREFIX}${sessionId}`, did, {
- ex: SESSION_TTL,
- })
+ try {
+ const redis = getRedis()
+ await redis.set(`${SESSION_DID_PREFIX}${sessionId}`, did, {
+ ex: SESSION_TTL,
+ })
+ } catch (err) {
+ console.error("[Session] Failed to store session in Redis:", err)
+ throw new Error("Failed to create session")
+ }
const cookieStore = await cookies()
cookieStore.set(COOKIE_NAME, cookieValue, {
@@ -62,9 +61,14 @@ export async function getSessionDid(): Promise {
return null
}
- const redis = getRedis()
- const did = await redis.get(`${SESSION_DID_PREFIX}${sessionId}`)
- return did ?? null
+ try {
+ const redis = getRedis()
+ const did = await redis.get(`${SESSION_DID_PREFIX}${sessionId}`)
+ return did ?? null
+ } catch (err) {
+ console.error("[Session] Failed to read session from Redis:", err)
+ return null
+ }
}
export async function deleteSession(): Promise {
@@ -75,8 +79,12 @@ export async function deleteSession(): Promise {
const dotIndex = cookie.value.lastIndexOf(".")
if (dotIndex !== -1) {
const sessionId = cookie.value.slice(0, dotIndex)
- const redis = getRedis()
- await redis.del(`${SESSION_DID_PREFIX}${sessionId}`)
+ try {
+ const redis = getRedis()
+ await redis.del(`${SESSION_DID_PREFIX}${sessionId}`)
+ } catch (err) {
+ console.error("[Session] Failed to delete session from Redis:", err)
+ }
}
}
diff --git a/src/middleware.ts b/src/middleware.ts
new file mode 100644
index 00000000..32b17fc5
--- /dev/null
+++ b/src/middleware.ts
@@ -0,0 +1,15 @@
+import { NextRequest, NextResponse } from "next/server"
+
+export function middleware(request: NextRequest) {
+ const hasSession = request.cookies.has("certified_session")
+
+ if (!hasSession) {
+ return NextResponse.redirect(new URL("/welcome", request.url))
+ }
+
+ return NextResponse.next()
+}
+
+export const config = {
+ matcher: ["/"],
+}