Skip to content

Commit a458ad8

Browse files
authored
Improve security and add utility components for gating access (#15)
* Implement same security level for images as well * Implement trpc panel for api docs * Fix build issues with server code on client * Implement backbone functions for page and component security - client and server side (client side are not meant for security, only UX) * Implement security hooks and component in other pages * Add guest user group to groups and make unassignable * Refactor permission components to be more structurized and less confusing * Fix RequirePermission component to support any * Fix type errors * Add PermissionGates to some wiki pages
1 parent 71c544e commit a458ad8

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

67 files changed

+3793
-628
lines changed

.env.example

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,6 @@ GITHUB_CLIENT_ID=your-github-client-id
1010
GITHUB_CLIENT_SECRET=your-github-client-secret
1111

1212
GOOGLE_CLIENT_ID=your-google-client-id
13-
GOOGLE_CLIENT_SECRET=your-google-client-secret
13+
GOOGLE_CLIENT_SECRET=your-google-client-secret
14+
15+
NEXT_PUBLIC_DEV_MODE=true

docs/permissions.md

Lines changed: 278 additions & 0 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"@codemirror/language": "^6.11.0",
2626
"@codemirror/language-data": "^6.5.1",
2727
"@codemirror/state": "^6.5.2",
28+
"@heroicons/react": "^2.2.0",
2829
"@lezer/highlight": "^1.2.1",
2930
"@neondatabase/serverless": "^1.0.0",
3031
"@radix-ui/react-dialog": "^1.1.10",
@@ -107,6 +108,7 @@
107108
"postcss": "^8.5.3",
108109
"tailwind-merge": "^3.2.0",
109110
"tailwindcss": "^4.1.4",
111+
"trpc-ui": "^1.0.15",
110112
"tsx": "^4.19.3",
111113
"typescript": "^5.8.3"
112114
}

pnpm-lock.yaml

Lines changed: 891 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app/(auth)/layout.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default function LoginLayout({
2+
children,
3+
}: {
4+
children: React.ReactNode;
5+
}) {
6+
return <div>{children}</div>;
7+
}

src/app/(auth)/login/page.tsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"use client";
2+
3+
import { LoginForm } from "~/components/auth/LoginForm";
4+
import { Suspense, useEffect } from "react";
5+
import { useRouter } from "next/navigation";
6+
import { usePermissions } from "~/components/auth/permission/client";
7+
import { Button } from "~/components/ui/button";
8+
import { ArrowLeftIcon } from "lucide-react";
9+
10+
export default function LoginPage() {
11+
const router = useRouter();
12+
const { isAuthenticated, hasPermission } = usePermissions();
13+
14+
useEffect(() => {
15+
if (isAuthenticated) {
16+
router.push("/");
17+
}
18+
}, [isAuthenticated, router]);
19+
20+
const hasWikiReadPermission = hasPermission("wiki:page:read");
21+
22+
return (
23+
<div className="flex flex-col items-center justify-center min-h-screen px-4 py-12 sm:px-6 lg:px-8 bg-background-paper">
24+
<div className="w-full max-w-md space-y-8">
25+
<div>
26+
<h2 className="mt-6 text-3xl font-bold tracking-tight text-center">
27+
Sign in to NextWiki
28+
</h2>
29+
{!hasWikiReadPermission && (
30+
<p className="mt-2 text-sm text-center text-text-secondary">
31+
This is a private wiki. You need to be logged in to access it.
32+
</p>
33+
)}
34+
</div>
35+
36+
<Suspense fallback={<div>Loading login form...</div>}>
37+
<LoginForm />
38+
</Suspense>
39+
</div>
40+
41+
{/* Show the back to home button if the user has the wiki:page:read permission, otherwise they will be redirected back here so no need to show it */}
42+
{hasWikiReadPermission && (
43+
<Button
44+
className="fixed rounded-full bottom-16 left-16"
45+
variant="outlined"
46+
onClick={() => router.push("/")}
47+
>
48+
<ArrowLeftIcon className="w-4 h-4" />
49+
Back to home
50+
</Button>
51+
)}
52+
</div>
53+
);
54+
}

src/app/(auth)/register/page.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"use client";
2+
3+
import { ArrowLeftIcon } from "lucide-react";
4+
import { useRouter } from "next/navigation";
5+
import { useEffect } from "react";
6+
import { RegisterForm } from "~/components/auth/RegisterForm";
7+
import { usePermissions } from "~/components/auth/permission/client";
8+
import { Button } from "~/components/ui/button";
9+
10+
export default function RegisterPage({
11+
isFirstUser = false,
12+
}: {
13+
isFirstUser?: boolean;
14+
}) {
15+
const router = useRouter();
16+
const { isAuthenticated } = usePermissions();
17+
18+
useEffect(() => {
19+
if (isAuthenticated) {
20+
router.push("/");
21+
}
22+
}, [isAuthenticated, router]);
23+
24+
return (
25+
<div className="flex flex-col items-center justify-center h-screen px-4 py-12 sm:px-6 lg:px-8 bg-background-paper">
26+
<div className="w-full max-w-md space-y-8">
27+
<div>
28+
<h2 className="mt-6 text-3xl font-bold tracking-tight text-center">
29+
{isFirstUser ? "Create Admin Account" : "Create Account"}
30+
</h2>
31+
{isFirstUser && (
32+
<p className="mt-2 text-sm text-center text-text-secondary">
33+
This will be the first user and will have admin privileges
34+
</p>
35+
)}
36+
</div>
37+
38+
<RegisterForm isFirstUser={isFirstUser} />
39+
40+
<Button
41+
className="fixed rounded-full bottom-16 left-16"
42+
variant="outlined"
43+
onClick={() => router.push("/")}
44+
>
45+
<ArrowLeftIcon className="w-4 h-4" />
46+
Back to home
47+
</Button>
48+
</div>
49+
</div>
50+
);
51+
}

src/app/[...path]/page.tsx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { notFound } from "next/navigation";
1+
import { notFound, redirect } from "next/navigation";
22
import { MainLayout } from "~/components/layout/MainLayout";
33
import { WikiPage } from "~/components/wiki/WikiPage";
44
import { WikiEditor } from "~/components/wiki/WikiEditor";
@@ -11,6 +11,7 @@ import { authOptions } from "~/lib/auth";
1111
import { Suspense } from "react";
1212
import { PageLocationEditor } from "~/components/wiki/PageLocationEditor";
1313
import { renderWikiMarkdownToHtml } from "~/lib/services/markdown";
14+
import { authorizationService } from "~/lib/services/authorization";
1415

1516
export const dynamic = "auto";
1617
export const revalidate = 300; // 5 minutes
@@ -70,12 +71,22 @@ export default async function WikiPageView({
7071
const resolvedParams = await params;
7172
const resolvedSearchParams = await searchParams;
7273

73-
const page = await getWikiPageByPath(resolvedParams.path);
7474
const session = await getServerSession(authOptions);
7575
const currentUserId = session?.user?.id
7676
? parseInt(session.user.id)
7777
: undefined;
7878

79+
const canAccessPage = await authorizationService.hasPermission(
80+
currentUserId,
81+
"wiki:page:read"
82+
);
83+
84+
if (!canAccessPage) {
85+
redirect("/");
86+
}
87+
88+
const page = await getWikiPageByPath(resolvedParams.path);
89+
7990
if (!page) {
8091
notFound();
8192
}
@@ -86,6 +97,13 @@ export default async function WikiPageView({
8697

8798
// Edit mode
8899
if (isEditMode) {
100+
const canEditPage = await authorizationService.hasPermission(
101+
currentUserId,
102+
"wiki:page:update"
103+
);
104+
if (!canEditPage) {
105+
redirect("/");
106+
}
89107
return (
90108
<WikiEditor
91109
mode="edit"
@@ -100,6 +118,13 @@ export default async function WikiPageView({
100118

101119
// Move mode
102120
if (isMoveMode) {
121+
const canMovePage = await authorizationService.hasPermission(
122+
currentUserId,
123+
"wiki:page:move"
124+
);
125+
if (!canMovePage) {
126+
redirect("/");
127+
}
103128
return (
104129
<MainLayout>
105130
<PageLocationEditor

src/app/admin/example/page.tsx

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { Metadata } from "next";
2+
import { notFound } from "next/navigation";
3+
import { PermissionGate } from "~/components/auth/permission/server";
4+
import { PermissionsExample } from "~/components/auth/PermissionsExample";
5+
import { env } from "~/env";
6+
7+
export const metadata: Metadata = {
8+
title: "Permission Examples | NextWiki",
9+
description: "Server-side permission checking examples",
10+
};
11+
12+
export default function PermissionExamplesPage() {
13+
if (env.NODE_ENV !== "development") {
14+
notFound();
15+
}
16+
17+
return (
18+
<div className="container p-6 mx-auto">
19+
<h1 className="mb-6 text-3xl font-bold">Permission Examples</h1>
20+
21+
<div className="p-6 mb-6 rounded-lg shadow-sm bg-background-level1">
22+
<h2 className="mb-4 text-xl font-semibold">Single Permission Check</h2>
23+
24+
<PermissionGate permission="wiki:page:read">
25+
<PermissionGate.Authorized>
26+
<div className="p-4 rounded bg-success-light text-success">
27+
You have permission to read wiki pages.
28+
</div>
29+
</PermissionGate.Authorized>
30+
<PermissionGate.Unauthorized>
31+
<div className="p-4 rounded bg-warning-light text-warning">
32+
You do not have permission to read wiki pages.
33+
</div>
34+
</PermissionGate.Unauthorized>
35+
</PermissionGate>
36+
</div>
37+
38+
<div className="p-6 mb-6 rounded-lg shadow-sm bg-background-level1">
39+
<h2 className="mb-4 text-xl font-semibold">
40+
Multiple Permissions Check (Any)
41+
</h2>
42+
43+
<PermissionGate permissions={["wiki:page:create", "wiki:page:update"]}>
44+
<PermissionGate.Authorized>
45+
<div className="p-4 rounded bg-success-light text-success">
46+
You have permission to create or update wiki pages.
47+
</div>
48+
</PermissionGate.Authorized>
49+
<PermissionGate.Unauthorized>
50+
<div className="p-4 rounded bg-warning-light text-warning">
51+
You do not have permission to create or update wiki pages.
52+
</div>
53+
</PermissionGate.Unauthorized>
54+
</PermissionGate>
55+
</div>
56+
57+
<div className="p-6 rounded-lg shadow-sm bg-background-level1">
58+
<h2 className="mb-4 text-xl font-semibold">With Redirect Example</h2>
59+
<p className="mb-6 text-sm text-text-secondary">
60+
In this example, if you don&apos;t have admin permissions, you would
61+
be redirected to the login page. Since we want to show this example,
62+
we&apos;re not using the redirect here, but it would look like:
63+
</p>
64+
<pre className="p-4 overflow-x-auto rounded bg-code-bg text-code-text">
65+
{`<PermissionGate.Root
66+
permission="system:admin:access"
67+
>
68+
<PermissionGate.Authorized>
69+
Admin content here
70+
</PermissionGate.Authorized>
71+
<PermissionGate.Unauthorized redirectTo="/login">
72+
Redirecting to login...
73+
</PermissionGate.Unauthorized>
74+
</PermissionGate.Root>`}
75+
</pre>
76+
</div>
77+
78+
<div className="p-6 mt-6 rounded-lg shadow-sm bg-background-level1">
79+
<h2 className="mb-4 text-xl font-semibold">
80+
Client-side permission checking
81+
</h2>
82+
<PermissionsExample />
83+
</div>
84+
</div>
85+
);
86+
}

src/app/admin/groups/[id]/edit/page.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,13 @@ export default async function EditGroupPage({ params }: EditGroupPageProps) {
7171
<GroupForm
7272
group={{
7373
...group,
74-
isLocked: group.isLocked === null ? undefined : group.isLocked,
74+
isSystem: group.isSystem === null ? undefined : group.isSystem,
75+
isEditable:
76+
group.isEditable === null ? undefined : group.isEditable,
77+
allowUserAssignment:
78+
group.allowUserAssignment === null
79+
? undefined
80+
: group.allowUserAssignment,
7581
}}
7682
permissions={permissions}
7783
groupPermissions={groupPermissionIds}

0 commit comments

Comments
 (0)