Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion e2e/rbac.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
23 changes: 23 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
74 changes: 54 additions & 20 deletions src/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div
role="status"
aria-live="polite"
className="fixed inset-0 z-50 flex flex-col items-center justify-center gap-4 bg-background"
>
<ShieldCheck aria-hidden className="size-10 animate-pulse text-primary" />
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 aria-hidden className="size-4 animate-spin" />
Opening your workspace...
</div>
</div>
)
}

export default function LoginPage() {
const [error, setError] = useState<string | null>(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 | "password" | "passkey" | "totp" | "otp">(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)
Expand All @@ -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
Expand All @@ -67,27 +100,26 @@ export default function LoginPage() {

async function handleEmailVerify(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setLoading(true)
setPending("otp")
setError(null)

const form = new FormData(e.currentTarget)
const { error } = await twoFactor.verifyOtp({
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<HTMLFormElement>) {
e.preventDefault()
setLoading(true)
setPending("password")
setError(null)

const form = new FormData(e.currentTarget)
Expand All @@ -96,41 +128,43 @@ 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<HTMLFormElement>) {
e.preventDefault()
setLoading(true)
setPending("totp")
setError(null)

const form = new FormData(e.currentTarget)
const { error } = await twoFactor.verifyTotp({
code: String(form.get("code")),
})

setLoading(false)

if (error) {
setPending(null)
setError("Invalid code")
return
}

router.push("/dashboard")
enterWorkspace()
}

if (entering) return <EnteringWorkspace />

return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="w-full max-w-sm space-y-8 px-4">
Expand Down Expand Up @@ -306,7 +340,7 @@ export default function LoginPage() {
className="w-full"
size="lg"
>
{loading ? "Signing in..." : "Sign in"}
{pending === "password" ? "Signing in..." : "Sign in"}
</Button>

<div className="flex items-center gap-3">
Expand Down
43 changes: 22 additions & 21 deletions src/app/(dashboard)/access/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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 (
<main className="mx-auto max-w-4xl space-y-6 p-6">
<div>
<h1 className="text-lg font-semibold text-foreground">Access management</h1>
<p className="text-sm text-muted-foreground">Roles, assignments, and the audit trail.</p>
<div className="h-full overflow-y-auto p-6">
<div className="mb-6">
<h2 className="text-lg font-semibold text-foreground">Access</h2>
<p className="text-sm text-muted-foreground">
Who can do what in this workspace, and a record of every change
</p>
</div>
<section className="space-y-2">
<h2 className="text-sm font-medium text-foreground">Roles</h2>
<RolesManager />
</section>
{canManageUsers && (
<section className="space-y-2">
<h2 className="text-sm font-medium text-foreground">People</h2>
<UserRoleAssignment />
</section>
)}
{canReadAudit && (
<section className="space-y-2">
<h2 className="text-sm font-medium text-foreground">Audit trail</h2>
<AuditTrail />
</section>
)}
</main>

<AccessTabs
tabs={[
{ id: "roles", label: "Roles", panel: <RolesManager /> },
...(canManageUsers
? [{ id: "people", label: "People", panel: <UserRoleAssignment /> }]
: []),
...(canReadAudit ? [{ id: "audit", label: "Audit trail", panel: <AuditTrail /> }] : []),
]}
/>
</div>
)
}
31 changes: 0 additions & 31 deletions src/app/(dashboard)/loading.tsx

This file was deleted.

Loading