Skip to content

Commit 7cba2ae

Browse files
committed
Enforce signed-in article authorship
Load author profiles from the authenticated user and prevent manual author selection. Add Firestore configuration and rules to validate authorship on article creation while protecting it on updates.
1 parent 5f2f247 commit 7cba2ae

3 files changed

Lines changed: 50 additions & 92 deletions

File tree

app/admin/articles/new/page.tsx

Lines changed: 39 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22

33
import { useState, useEffect, useRef, useCallback } from "react";
44
import { useRouter } from "next/navigation";
5-
import { db, auth } from "@/lib/firebase";
5+
import { db } from "@/lib/firebase";
66
import {
77
collection,
88
setDoc,
99
serverTimestamp,
1010
doc,
11+
getDoc,
1112
getDocs,
1213
Timestamp,
1314
} from "firebase/firestore";
@@ -31,13 +32,7 @@ import { convertImageToWebP } from "@/lib/image-utils";
3132
import { useDropzone } from "react-dropzone";
3233
import { useAutosave } from "@/hooks/use-autosave";
3334
import { format } from "date-fns";
34-
35-
// Type for an author document
36-
interface Author {
37-
id: string;
38-
name: string;
39-
uid: string;
40-
}
35+
import { useAuth } from "@/lib/auth-context";
4136

4237
export default function NewArticlePage() {
4338
// Generate article ID on mount for asset uploads
@@ -60,12 +55,7 @@ export default function NewArticlePage() {
6055
const [previewHtml, setPreviewHtml] = useState("");
6156
const contentRef = useRef<HTMLTextAreaElement>(null!);
6257

63-
// Author autocomplete state
6458
const [authorName, setAuthorName] = useState("");
65-
const [selectedAuthor, setSelectedAuthor] = useState<Author | null>(null);
66-
const [authors, setAuthors] = useState<Author[]>([]);
67-
const [showSuggestions, setShowSuggestions] = useState(false);
68-
const suggestionsRef = useRef<HTMLDivElement>(null);
6959

7060
// Label autocomplete state
7161
const [existingLabels, setExistingLabels] = useState<string[]>([]);
@@ -91,6 +81,7 @@ export default function NewArticlePage() {
9181

9282
const router = useRouter();
9383
const { toast } = useToast();
84+
const { user } = useAuth();
9485

9586
// Custom thumbnail uploader using dropzone
9687
const { getRootProps, getInputProps, isDragActive } = useDropzone({
@@ -126,27 +117,35 @@ export default function NewArticlePage() {
126117
maxFiles: 1,
127118
});
128119

129-
// Fetch authors on mount
120+
// Always attribute new articles to the signed-in author.
130121
useEffect(() => {
131-
const fetchAuthors = async () => {
122+
const fetchCurrentAuthor = async () => {
123+
if (!user) {
124+
setAuthorName("");
125+
return;
126+
}
127+
132128
try {
133-
const snap = await getDocs(collection(db, "authors"));
134-
const docs = snap.docs.map((doc) => ({
135-
id: doc.id,
136-
...doc.data(),
137-
})) as Author[];
138-
setAuthors(docs);
129+
const snap = await getDoc(doc(db, "authors", user.uid));
130+
const name = snap.data()?.name;
131+
132+
if (typeof name !== "string" || !name.trim()) {
133+
throw new Error("Author profile has no name");
134+
}
135+
136+
setAuthorName(name);
139137
} catch (error) {
140-
console.error("Error fetching authors:", error);
138+
console.error("Error fetching current author:", error);
139+
setAuthorName("");
141140
toast({
142141
title: "Error",
143-
description: "Failed to load authors",
142+
description: "Failed to load your author profile",
144143
variant: "destructive",
145144
});
146145
}
147146
};
148-
fetchAuthors();
149-
}, [toast]);
147+
fetchCurrentAuthor();
148+
}, [user, toast]);
150149

151150
// Fetch existing labels from articles
152151
useEffect(() => {
@@ -176,32 +175,9 @@ export default function NewArticlePage() {
176175
}
177176
}, [title]);
178177

179-
// Update selected author if input changes
180-
useEffect(() => {
181-
if (authorName.trim() === "") {
182-
setSelectedAuthor(null);
183-
return;
184-
}
185-
const match = authors.find((a) =>
186-
a.name.toLowerCase().includes(authorName.toLowerCase()),
187-
);
188-
189-
if (match) {
190-
setSelectedAuthor(match);
191-
} else {
192-
setSelectedAuthor(null);
193-
}
194-
}, [authorName, authors]);
195-
196178
// Hide suggestions on click outside
197179
useEffect(() => {
198180
const handleClickOutside = (event: MouseEvent) => {
199-
if (
200-
suggestionsRef.current &&
201-
!suggestionsRef.current.contains(event.target as Node)
202-
) {
203-
setShowSuggestions(false);
204-
}
205181
if (
206182
labelSuggestionsRef.current &&
207183
!labelSuggestionsRef.current.contains(event.target as Node)
@@ -304,15 +280,14 @@ export default function NewArticlePage() {
304280
}
305281
if (!authorName.trim()) {
306282
toast({
307-
title: "Missing author",
308-
description: "Please select an author for the article",
283+
title: "Author unavailable",
284+
description: "Your author profile could not be loaded",
309285
variant: "destructive",
310286
});
311287
throw new Error("Missing author");
312288
}
313289
}
314290

315-
const user = auth.currentUser;
316291
if (!user) {
317292
if (isManual) {
318293
toast({
@@ -342,11 +317,9 @@ export default function NewArticlePage() {
342317
popularity,
343318
read: readTime,
344319
slug,
345-
authorName: selectedAuthor ? selectedAuthor.name : authorName,
346-
authorUID: selectedAuthor ? selectedAuthor.uid : user.uid,
347-
authorRef: selectedAuthor
348-
? doc(db, "authors", selectedAuthor.id)
349-
: doc(db, "authors", user.uid),
320+
authorName,
321+
authorUID: user.uid,
322+
authorRef: doc(db, "authors", user.uid),
350323

351324
// Set createdAt/date if not exists (merge will keep existing)
352325
// Actually serverTimestamp() will always update.
@@ -410,12 +383,12 @@ export default function NewArticlePage() {
410383
readTime,
411384
slug,
412385
authorName,
413-
selectedAuthor,
414386
isPublished,
415387
scheduledDate,
416388
articleId,
417389
router,
418390
toast,
391+
user,
419392
],
420393
);
421394

@@ -487,43 +460,18 @@ export default function NewArticlePage() {
487460
</p>
488461
</div>
489462

490-
{/* Author Name with Autocomplete */}
491-
<div className="mb-6 relative">
463+
{/* Author */}
464+
<div className="mb-6">
492465
<label className="block mb-2 font-medium">Author:</label>
493466
<Input
494467
value={authorName}
495-
onChange={(e) => {
496-
setAuthorName(e.target.value);
497-
setShowSuggestions(true);
498-
}}
499-
onFocus={() => setShowSuggestions(true)}
500-
placeholder="Start typing author name..."
501-
className="w-full"
468+
disabled
469+
placeholder="Loading your author profile..."
470+
className="w-full opacity-70 cursor-not-allowed"
502471
/>
503-
{showSuggestions && authors.length > 0 && (
504-
<div
505-
ref={suggestionsRef}
506-
className="absolute z-10 w-full border border-white/60 bg-[#1a1a1a] mt-1 max-h-48 overflow-y-auto"
507-
>
508-
{authors
509-
.filter((a) =>
510-
a.name.toLowerCase().includes(authorName.toLowerCase()),
511-
)
512-
.map((a) => (
513-
<div
514-
key={a.id}
515-
className="px-4 py-2 hover:bg-[#8a2be2]/20 cursor-pointer"
516-
onClick={() => {
517-
setAuthorName(a.name);
518-
setSelectedAuthor(a);
519-
setShowSuggestions(false);
520-
}}
521-
>
522-
{a.name}
523-
</div>
524-
))}
525-
</div>
526-
)}
472+
<p className="text-sm text-white/50 mt-1">
473+
Automatically set from your signed-in account
474+
</p>
527475
</div>
528476

529477
{/* Toggle Editor/Preview/Assets for Content */}
@@ -771,7 +719,7 @@ export default function NewArticlePage() {
771719
<div className="flex flex-wrap gap-4 mt-8">
772720
<Button
773721
onClick={handleManualSave}
774-
disabled={creating}
722+
disabled={creating || !authorName}
775723
variant="outline"
776724
>
777725
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}

firebase.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
{
2+
"firestore": {
3+
"rules": "firestore.rules"
4+
},
25
"functions": [
36
{
47
"source": "functions",

firestore.rules

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,14 @@ service cloud.firestore {
2525
// Articles: Public read, Admin write
2626
match /articles/{articleId} {
2727
allow read: if true;
28-
allow write: if isAdmin();
28+
allow create: if isAdmin() &&
29+
request.resource.data.authorUID == request.auth.uid &&
30+
request.resource.data.authorRef == /databases/$(database)/documents/authors/$(request.auth.uid) &&
31+
request.resource.data.authorName == get(/databases/$(database)/documents/authors/$(request.auth.uid)).data.name;
32+
allow update: if isAdmin() &&
33+
!request.resource.data.diff(resource.data).affectedKeys()
34+
.hasAny(['authorUID', 'authorRef', 'authorName']);
35+
allow delete: if isAdmin();
2936
}
3037

3138
// News: Public read, Admin write

0 commit comments

Comments
 (0)