Skip to content
Closed
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
4 changes: 3 additions & 1 deletion scripts/ensure-admin-build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const file = path.join(root, "src", "routes", "_authenticated", "admin.tsx");
let source = fs.readFileSync(file, "utf8");
const lineEnding = source.includes("\r\n") ? "\r\n" : "\n";
source = source.replace(/\r\n/g, "\n");

// Keep the existing production build guard for the historical duplicate Metric.
const duplicate = `\nfunction Metric({ label, value }: { label: string; value: number }) { return <div className="glass rounded-2xl p-5"><div className="text-xs text-muted-foreground">{label}</div><div className="mt-1 font-display text-3xl font-bold gradient-text">{value}</div></div>; }`;
Expand Down Expand Up @@ -50,5 +52,5 @@ if (!source.includes(devicesRender)) {
source = source.replace(anchor, `${anchor}\n ${devicesRender}`);
}

fs.writeFileSync(file, source, "utf8");
fs.writeFileSync(file, source.replace(/\n/g, lineEnding), "utf8");
console.log("Admin Devices integration: ready");
16 changes: 16 additions & 0 deletions src/components/PrivacyBlur.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useEffect, useState } from "react";

export function PrivacyBlur({ children }: { children: React.ReactNode }) {
const [isHidden, setIsHidden] = useState(false);

useEffect(() => {
const handleVisibilityChange = () => {
setIsHidden(document.visibilityState === "hidden");
};

document.addEventListener("visibilitychange", handleVisibilityChange);
return () => document.removeEventListener("visibilitychange", handleVisibilityChange);
}, []);

return <div className={isHidden ? "privacy-blur" : undefined}>{children}</div>;
}
15 changes: 15 additions & 0 deletions src/components/PrivacyCopyrightNotice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export function PrivacyCopyrightNotice() {
return (
<aside
className="privacy-copyright-notice glass mx-auto mt-10 max-w-7xl rounded-2xl px-4 py-4 text-sm leading-6 text-muted-foreground"
aria-label="Privacy and copyright notice"
>
<p>
<span className="font-semibold text-foreground">Copyright and privacy notice:</span>{" "}
Protected content on CodeForge is copyrighted. Unauthorized reproduction, redistribution,
or commercial use is prohibited. Privacy safeguards are enabled, but screenshots and other
forms of capture cannot be universally prevented by a website.
</p>
</aside>
);
}
25 changes: 25 additions & 0 deletions src/components/ProtectedImage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { ImgHTMLAttributes } from "react";

type ProtectedImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "draggable"> & {
watermark?: string;
};

export function ProtectedImage({ watermark = "© CodeForge", className, alt = "", ...props }: ProtectedImageProps) {
return (
<span
className="protected-image relative block overflow-hidden rounded-xl"
onContextMenu={(event) => event.preventDefault()}
onDragStart={(event) => event.preventDefault()}
>
<img
{...props}
alt={alt}
draggable={false}
className={className ? `protected-image__asset ${className}` : "protected-image__asset"}
/>
<span className="protected-image__watermark" aria-hidden="true">
{watermark}
</span>
</span>
);
}
2 changes: 2 additions & 0 deletions src/components/SiteLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { ReactNode } from "react";
import { Navbar } from "./Navbar";
import { Footer } from "./Footer";
import { PrivacyCopyrightNotice } from "./PrivacyCopyrightNotice";

export function SiteLayout({ children }: { children: ReactNode }) {
return (
<div className="relative flex min-h-screen flex-col">
<Navbar />
<main className="flex-1">{children}</main>
<PrivacyCopyrightNotice />
<Footer />
</div>
);
Expand Down
8 changes: 2 additions & 6 deletions src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,7 @@ function RootShell({ children }: { children: ReactNode }) {
<head>
<HeadContent />
</head>
<body
onContextMenu={(event) => event.preventDefault()}
onCopy={(event) => event.preventDefault()}
onCut={(event) => event.preventDefault()}
>
<body>
{children}
<Scripts />
</body>
Expand All @@ -149,4 +145,4 @@ function RootComponent() {
</AuthProvider>
</QueryClientProvider>
);
}
}
42 changes: 6 additions & 36 deletions src/routes/_authenticated/route.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,25 @@
/**
* _authenticated layout — guards /dashboard and /admin
*
* Logic:
* 1. No session → /auth
* 2. No verified TOTP factor → /mfa-setup
* 3. Has verified TOTP factor BUT session is only AAL1 → /mfa-verify
* 4. AAL2 session → allow through
*
* MFA is mandatory for the admin account.
*
* Device-session monitoring:
* - Registers the current admin browser/device.
* - Periodically asks Supabase Auth whether the session still exists.
* - A session revoked from Admin → Devices is therefore detected and the
* remote browser is signed out through the normal local logout flow.
*/
import { createFileRoute, Outlet, redirect, useNavigate } from "@tanstack/react-router";
import { useEffect } from "react";
import { supabase } from "@/integrations/supabase/client";
import {
registerCurrentAdminSession,
touchCurrentAdminSession,
} from "@/lib/admin-sessions";
import { PrivacyBlur } from "@/components/PrivacyBlur";

export const Route = createFileRoute("/_authenticated")({
ssr: false,
head: () => ({ meta: [{ name: "robots", content: "noindex, nofollow" }] }),
beforeLoad: async () => {
// 1. Require a valid session. getUser() performs a network check against
// Supabase Auth, so revoked remote sessions are not treated as valid.
const { data: userData, error } = await supabase.auth.getUser();
if (error || !userData.user) throw redirect({ to: "/auth" });

// 2. Check if this user has MFA enrolled
const { data: factors } = await supabase.auth.mfa.listFactors();
const hasVerifiedFactor = (factors?.totp ?? []).length > 0;
if (!hasVerifiedFactor) throw redirect({ to: "/mfa-setup" });

if (!hasVerifiedFactor) {
throw redirect({ to: "/mfa-setup" });
}

{
// User has MFA — enforce AAL2 for this session
const { data: aalData } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel();
if (aalData?.currentLevel !== "aal2") {
// Session is password-only — redirect to MFA verification
throw redirect({ to: "/mfa-verify" });
}
}
const { data: aalData } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel();
if (aalData?.currentLevel !== "aal2") throw redirect({ to: "/mfa-verify" });

return { user: userData.user };
},
Expand All @@ -69,20 +42,17 @@ function AuthenticatedLayout() {
return;
}

// Only admin accounts are registered in the Devices panel. Failure to
// register must not block the existing authenticated route.
await registerCurrentAdminSession();
await touchCurrentAdminSession();
};

void monitorSession();
const timer = window.setInterval(() => void monitorSession(), 15_000);

return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [navigate]);

return <Outlet />;
return <PrivacyBlur><Outlet /></PrivacyBlur>;
}
37 changes: 13 additions & 24 deletions vercel.json
Original file line number Diff line number Diff line change
@@ -1,33 +1,22 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"headers": [
{ "source": "/auth", "headers": [{ "key": "Cache-Control", "value": "no-store" }] },
{ "source": "/forgot-password", "headers": [{ "key": "Cache-Control", "value": "no-store" }] },
{ "source": "/reset-password", "headers": [{ "key": "Cache-Control", "value": "no-store" }] },
{ "source": "/mfa-setup", "headers": [{ "key": "Cache-Control", "value": "no-store" }] },
{ "source": "/mfa-verify", "headers": [{ "key": "Cache-Control", "value": "no-store" }] },
{ "source": "/dashboard/:path*", "headers": [{ "key": "Cache-Control", "value": "no-store" }] },
{ "source": "/admin/:path*", "headers": [{ "key": "Cache-Control", "value": "no-store" }] },
{
"source": "/(.*)",
"headers": [
{
"key": "Content-Security-Policy",
"value": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob: https://*.supabase.co; connect-src 'self' https://*.supabase.co https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; worker-src 'self' blob:; manifest-src 'self'; media-src 'self'; upgrade-insecure-requests"
},
{
"key": "X-Content-Type-Options",
"value": "nosniff"
},
{
"key": "X-Frame-Options",
"value": "DENY"
},
{
"key": "Referrer-Policy",
"value": "strict-origin-when-cross-origin"
},
{
"key": "Permissions-Policy",
"value": "camera=(), microphone=(), geolocation=(), payment=(), usb=()"
},
{
"key": "Strict-Transport-Security",
"value": "max-age=31536000; includeSubDomains"
}
{ "key": "Content-Security-Policy", "value": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob: https://*.supabase.co; connect-src 'self' https://*.supabase.co https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; worker-src 'self' blob:; manifest-src 'self'; media-src 'self'; upgrade-insecure-requests" },
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "X-Frame-Options", "value": "DENY" },
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
{ "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=(), payment=(), usb=()" },
{ "key": "Strict-Transport-Security", "value": "max-age=31536000; includeSubDomains" }
]
}
]
Expand Down
Loading