diff --git a/e2e/rbac.spec.ts b/e2e/rbac.spec.ts
index b82730e..ded5e0c 100644
--- a/e2e/rbac.spec.ts
+++ b/e2e/rbac.spec.ts
@@ -20,7 +20,7 @@ async function login(page: import("@playwright/test").Page, u: { email: string;
test("a viewer can view roles but not create one", async ({ page }) => {
await login(page, MEMBER)
await page.goto("/access")
- await expect(page.getByRole("heading", { name: "Access management" })).toBeVisible()
+ await expect(page.getByRole("heading", { name: "Access" })).toBeVisible()
// Direct API create must be forbidden for a viewer.
const res = await page.request.post("/api/roles", {
@@ -30,6 +30,44 @@ test("a viewer can view roles but not create one", async ({ page }) => {
expect(res.status()).toBe(403)
})
+// The dashboard shell is h-screen with overflow-hidden at every level, so each
+// page owns its scrolling. Opening the permission editor makes /access taller
+// than the viewport, and without a scroll container the Cancel button sits below
+// the fold with no way to reach it: the panel cannot be closed.
+test("the permission editor stays reachable once it overflows the viewport", async ({ page }) => {
+ // Laptop height, not the 1280x720 default: the editor fits in 720 and the
+ // clipping only shows up on a shorter viewport.
+ await page.setViewportSize({ width: 1280, height: 620 })
+ await login(page, ADMIN)
+ await page.goto("/access")
+
+ await page.getByRole("button", { name: "Edit" }).first().click()
+ await expect(page.getByPlaceholder("Role name")).toBeVisible()
+
+ // Walk out from the form and fail on any ancestor that clips content it is
+ // too short to show. Asserting the Cancel button's position instead would
+ // pass or fail on how many roles happen to be seeded, and Playwright can
+ // force a click on a clipped node where a user cannot.
+ const clipped = await page.evaluate(() => {
+ let el: Element | null = document.querySelector('input[placeholder="Role name"]')
+ const bad: string[] = []
+ while (el && el !== document.documentElement) {
+ const overflowY = getComputedStyle(el).overflowY
+ if ((overflowY === "hidden" || overflowY === "clip") && el.scrollHeight > el.clientHeight + 1) {
+ bad.push(`${el.tagName.toLowerCase()} clips ${el.scrollHeight}px into ${el.clientHeight}px`)
+ }
+ el = el.parentElement
+ }
+ return bad
+ })
+ expect(clipped).toEqual([])
+
+ const cancel = page.getByRole("button", { name: "Cancel" })
+ await cancel.scrollIntoViewIfNeeded()
+ await cancel.click()
+ await expect(page.getByPlaceholder("Role name")).toBeHidden()
+})
+
// An admin creates a plain role through the UI. The crown-jewel step-up path is
// covered at the API level by the Task 8/9 integration tests.
// The name is unique per run and deleted afterwards: a fixed name would survive
diff --git a/prisma/migrations/20260804202845_add_rate_limit_tables/migration.sql b/prisma/migrations/20260804202845_add_rate_limit_tables/migration.sql
new file mode 100644
index 0000000..f835422
--- /dev/null
+++ b/prisma/migrations/20260804202845_add_rate_limit_tables/migration.sql
@@ -0,0 +1,24 @@
+-- CreateTable
+CREATE TABLE "RateLimit" (
+ "id" TEXT NOT NULL,
+ "key" TEXT NOT NULL,
+ "count" INTEGER NOT NULL,
+ "lastRequest" BIGINT NOT NULL,
+
+ CONSTRAINT "RateLimit_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "ApiRateLimit" (
+ "key" TEXT NOT NULL,
+ "count" INTEGER NOT NULL,
+ "reset" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "ApiRateLimit_pkey" PRIMARY KEY ("key")
+);
+
+-- CreateIndex
+CREATE INDEX "RateLimit_key_idx" ON "RateLimit"("key");
+
+-- CreateIndex
+CREATE INDEX "ApiRateLimit_reset_idx" ON "ApiRateLimit"("reset");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index cac340d..15a4815 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -538,3 +538,26 @@ enum AlertConfidence {
MEDIUM
LOW
}
+
+// Better Auth's own limiter, moved off in-memory storage so the sign-in window
+// is shared by every instance and survives a deploy. Field names and types are
+// dictated by Better Auth (storage: "database").
+model RateLimit {
+ id String @id
+ key String
+ count Int
+ lastRequest BigInt
+
+ @@index([key])
+}
+
+// The application limiter behind scan, SCIM and SIEM. Same reasoning: a
+// per-process Map divides the limit by the number of instances and resets on
+// every deploy.
+model ApiRateLimit {
+ key String @id
+ count Int
+ reset DateTime
+
+ @@index([reset])
+}
diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx
index 38bba21..7c8924e 100644
--- a/src/app/(auth)/login/page.tsx
+++ b/src/app/(auth)/login/page.tsx
@@ -2,17 +2,50 @@
import { useState } from "react"
import { useRouter } from "next/navigation"
+import { Loader2, ShieldCheck } from "lucide-react"
import { signIn, twoFactor } from "@/lib/auth/client"
import { Button } from "@/components/ui/button"
+// Covers the gap between "credentials accepted" and "dashboard painted". That
+// stretch is the slowest part of signing in and used to show nothing at all:
+// the button had already snapped back to its idle label. Rendered only once
+// authentication has actually succeeded, never during the check itself.
+function EnteringWorkspace() {
+ return (
+
+
+
+
+ Opening your workspace...
+
+
+ )
+}
+
export default function LoginPage() {
const [error, setError] = useState(null)
- const [loading, setLoading] = useState(false)
+ // Which action is in flight, not merely whether one is: the spinner belongs on
+ // the button that was actually clicked, and the passkey button must keep its
+ // own label so its accessible name never collides with "Sign in".
+ const [pending, setPending] = useState(null)
+ const loading = pending !== null
+ const [entering, setEntering] = useState(false)
const [needsTotp, setNeedsTotp] = useState(false)
const [emailMode, setEmailMode] = useState(false)
const [otpSent, setOtpSent] = useState(false)
const router = useRouter()
+ // Every successful path funnels through here so the transition screen is the
+ // single thing standing between authentication and the dashboard.
+ function enterWorkspace() {
+ setEntering(true)
+ router.push("/dashboard")
+ }
+
function chooseEmail() {
setError(null)
setEmailMode(true)
@@ -26,30 +59,30 @@ export default function LoginPage() {
}
async function handlePasskey() {
- setLoading(true)
+ setPending("passkey")
setError(null)
try {
const res = await signIn.passkey()
if (res?.error) {
setError("Passkey sign-in failed or was cancelled")
+ setPending(null)
return
}
- router.push("/dashboard")
+ enterWorkspace()
} catch {
setError("Passkey sign-in failed or was cancelled")
- } finally {
- setLoading(false)
+ setPending(null)
}
}
async function handleSendOtp() {
- setLoading(true)
+ setPending("otp")
setError(null)
const { error } = await twoFactor.sendOtp()
- setLoading(false)
+ setPending(null)
if (error) {
// 403 when the company's policy does not allow EMAIL_OTP; the server hook
@@ -67,7 +100,7 @@ export default function LoginPage() {
async function handleEmailVerify(e: React.FormEvent) {
e.preventDefault()
- setLoading(true)
+ setPending("otp")
setError(null)
const form = new FormData(e.currentTarget)
@@ -75,19 +108,18 @@ export default function LoginPage() {
code: String(form.get("code")),
})
- setLoading(false)
-
if (error) {
+ setPending(null)
setError("Invalid code")
return
}
- router.push("/dashboard")
+ enterWorkspace()
}
async function handlePassword(e: React.FormEvent) {
e.preventDefault()
- setLoading(true)
+ setPending("password")
setError(null)
const form = new FormData(e.currentTarget)
@@ -96,24 +128,25 @@ export default function LoginPage() {
password: String(form.get("password")),
})
- setLoading(false)
-
if (error) {
+ setPending(null)
setError("Invalid email or password")
return
}
if (data && "twoFactorRedirect" in data && data.twoFactorRedirect) {
+ // Stays on the page to ask for the second factor, so hand control back.
+ setPending(null)
setNeedsTotp(true)
return
}
- router.push("/dashboard")
+ enterWorkspace()
}
async function handleTotp(e: React.FormEvent) {
e.preventDefault()
- setLoading(true)
+ setPending("totp")
setError(null)
const form = new FormData(e.currentTarget)
@@ -121,16 +154,17 @@ export default function LoginPage() {
code: String(form.get("code")),
})
- setLoading(false)
-
if (error) {
+ setPending(null)
setError("Invalid code")
return
}
- router.push("/dashboard")
+ enterWorkspace()
}
+ if (entering) return
+
return (
diff --git a/src/app/(dashboard)/access/page.tsx b/src/app/(dashboard)/access/page.tsx
index 6129129..5e25e49 100644
--- a/src/app/(dashboard)/access/page.tsx
+++ b/src/app/(dashboard)/access/page.tsx
@@ -5,6 +5,7 @@ import { getUserPermissions, authorize } from "@/lib/rbac/authorize"
import { RolesManager } from "@/components/rbac/RolesManager"
import { UserRoleAssignment } from "@/components/rbac/UserRoleAssignment"
import { AuditTrail } from "@/components/rbac/AuditTrail"
+import { AccessTabs } from "@/components/rbac/AccessTabs"
export default async function AccessPage() {
const session = await getSession()
@@ -18,28 +19,28 @@ export default async function AccessPage() {
const canManageUsers = authorize(perms, "users:manage")
const canReadAudit = authorize(perms, "audit:read")
+ // The shell is h-screen with overflow-hidden at every level, so a page owns its
+ // own scrolling. Without the outer container the permission editor pushes the
+ // rest of the page past the fold with no way to reach it, and the panel can no
+ // longer be closed. Same shape as every other dashboard page.
return (
-
-
-
Access management
-
Roles, assignments, and the audit trail.
+
+
+
Access
+
+ Who can do what in this workspace, and a record of every change
+
)
}
diff --git a/src/app/(dashboard)/loading.tsx b/src/app/(dashboard)/loading.tsx
deleted file mode 100644
index 500922a..0000000
--- a/src/app/(dashboard)/loading.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-// Shown instantly while a dashboard page's server data loads, so navigation
-// feels immediate. Routes are also prefetched (see RoutePrefetcher), so only
-// the dynamic data fetch remains, and this skeleton covers it.
-export default function DashboardLoading() {
- return (
-