Skip to content

Commit be74b2b

Browse files
committed
✨ Allow admin users to remove/edit comments
1 parent e799113 commit be74b2b

File tree

5 files changed

+276
-49
lines changed

5 files changed

+276
-49
lines changed

components/comment/comment-item.tsx

Lines changed: 54 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ export default function CommentItem({
4747
const { data: user } = useUserQuery();
4848
const { openLogin } = useLoginDialog();
4949

50+
const isAdmin = user?.app_metadata?.userrole === "admin";
51+
5052
const [showReplyForm, setShowReplyForm] = useState(false);
5153
const [showReplies, setShowReplies] = useState(true);
5254
const [isEditing, setIsEditing] = useState(false);
@@ -227,55 +229,58 @@ export default function CommentItem({
227229
<MessageCircle className="size-3" />
228230
답글
229231
</Button>
230-
{user?.id === comment.author_id && !isEditing && (
231-
<>
232-
<Button
233-
variant="ghost"
234-
className="h-6 px-2.5 text-xs"
235-
onClick={() => setIsEditing(true)}
236-
>
237-
고치기
238-
</Button>
239-
<AlertDialog>
240-
<AlertDialogTrigger asChild>
241-
<Button
242-
type="button"
243-
variant="ghost"
244-
className="h-6 px-2.5 text-xs text-red-600 hover:text-red-700"
245-
>
246-
지우기
247-
</Button>
248-
</AlertDialogTrigger>
249-
<AlertDialogContent>
250-
<AlertDialogHeader>
251-
<AlertDialogTitle>
252-
정말로 댓글을 지울까요?
253-
</AlertDialogTitle>
254-
<AlertDialogDescription>
255-
이 작업은 되돌릴 수 없어요. 이미 답글이 달린 댓글은
256-
{" '지워진 댓글'"}로 표시돼요.
257-
</AlertDialogDescription>
258-
</AlertDialogHeader>
259-
{removeState?.error && (
260-
<span className="text-xs text-red-600">
261-
{removeState.error}
262-
</span>
263-
)}
264-
<AlertDialogFooter>
265-
<AlertDialogCancel>닫기</AlertDialogCancel>
266-
<Form action={removeAction}>
267-
<AlertDialogAction
268-
type="submit"
269-
className="bg-red-600 hover:bg-red-700"
270-
>
271-
지우기
272-
</AlertDialogAction>
273-
</Form>
274-
</AlertDialogFooter>
275-
</AlertDialogContent>
276-
</AlertDialog>
277-
</>
278-
)}
232+
{user &&
233+
(user.id === comment.author_id || isAdmin) &&
234+
!isEditing && (
235+
<>
236+
<Button
237+
variant="ghost"
238+
className="h-6 px-2.5 text-xs"
239+
onClick={() => setIsEditing(true)}
240+
>
241+
고치기
242+
</Button>
243+
<AlertDialog>
244+
<AlertDialogTrigger asChild>
245+
<Button
246+
type="button"
247+
variant="ghost"
248+
className="h-6 px-2.5 text-xs text-red-600 hover:text-red-700"
249+
>
250+
지우기
251+
</Button>
252+
</AlertDialogTrigger>
253+
<AlertDialogContent>
254+
<AlertDialogHeader>
255+
<AlertDialogTitle>
256+
정말로 댓글을 지울까요?
257+
</AlertDialogTitle>
258+
<AlertDialogDescription>
259+
이 작업은 되돌릴 수 없어요. 이미 답글이 달린
260+
댓글은
261+
{" '지워진 댓글'"}로 표시돼요.
262+
</AlertDialogDescription>
263+
</AlertDialogHeader>
264+
{removeState?.error && (
265+
<span className="text-xs text-red-600">
266+
{removeState.error}
267+
</span>
268+
)}
269+
<AlertDialogFooter>
270+
<AlertDialogCancel>닫기</AlertDialogCancel>
271+
<Form action={removeAction}>
272+
<AlertDialogAction
273+
type="submit"
274+
className="bg-red-600 hover:bg-red-700"
275+
>
276+
지우기
277+
</AlertDialogAction>
278+
</Form>
279+
</AlertDialogFooter>
280+
</AlertDialogContent>
281+
</AlertDialog>
282+
</>
283+
)}
279284
</div>
280285
)}
281286

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
CREATE OR REPLACE FUNCTION is_claims_admin() RETURNS "bool"
2+
LANGUAGE "plpgsql"
3+
AS $$
4+
BEGIN
5+
IF session_user = 'authenticator' THEN
6+
--------------------------------------------
7+
-- To disallow any authenticated app users
8+
-- from editing claims, delete the following
9+
-- block of code and replace it with:
10+
-- RETURN FALSE;
11+
--------------------------------------------
12+
IF extract(epoch from now()) > coalesce((current_setting('request.jwt.claims', true)::jsonb)->>'exp', '0')::numeric THEN
13+
return false; -- jwt expired
14+
END IF;
15+
If current_setting('request.jwt.claims', true)::jsonb->>'role' = 'service_role' THEN
16+
RETURN true; -- service role users have admin rights
17+
END IF;
18+
IF coalesce((current_setting('request.jwt.claims', true)::jsonb)->'app_metadata'->'claims_admin', 'false')::bool THEN
19+
return true; -- user has claims_admin set to true
20+
ELSE
21+
return false; -- user does NOT have claims_admin set to true
22+
END IF;
23+
--------------------------------------------
24+
-- End of block
25+
--------------------------------------------
26+
ELSE -- not a user session, probably being called from a trigger or something
27+
return true;
28+
END IF;
29+
END;
30+
$$;
31+
32+
CREATE OR REPLACE FUNCTION get_my_claims() RETURNS "jsonb"
33+
LANGUAGE "sql" STABLE
34+
AS $$
35+
select
36+
coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb -> 'app_metadata', '{}'::jsonb)::jsonb
37+
$$;
38+
CREATE OR REPLACE FUNCTION get_my_claim(claim TEXT) RETURNS "jsonb"
39+
LANGUAGE "sql" STABLE
40+
AS $$
41+
select
42+
coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb -> 'app_metadata' -> claim, null)
43+
$$;
44+
45+
CREATE OR REPLACE FUNCTION get_claims(uid uuid) RETURNS "jsonb"
46+
LANGUAGE "plpgsql" SECURITY DEFINER SET search_path = public
47+
AS $$
48+
DECLARE retval jsonb;
49+
BEGIN
50+
IF NOT is_claims_admin() THEN
51+
RETURN '{"error":"access denied"}'::jsonb;
52+
ELSE
53+
select raw_app_meta_data from auth.users into retval where id = uid::uuid;
54+
return retval;
55+
END IF;
56+
END;
57+
$$;
58+
59+
CREATE OR REPLACE FUNCTION get_claim(uid uuid, claim text) RETURNS "jsonb"
60+
LANGUAGE "plpgsql" SECURITY DEFINER SET search_path = public
61+
AS $$
62+
DECLARE retval jsonb;
63+
BEGIN
64+
IF NOT is_claims_admin() THEN
65+
RETURN '{"error":"access denied"}'::jsonb;
66+
ELSE
67+
select coalesce(raw_app_meta_data->claim, null) from auth.users into retval where id = uid::uuid;
68+
return retval;
69+
END IF;
70+
END;
71+
$$;
72+
73+
CREATE OR REPLACE FUNCTION set_claim(uid uuid, claim text, value jsonb) RETURNS "text"
74+
LANGUAGE "plpgsql" SECURITY DEFINER SET search_path = public
75+
AS $$
76+
BEGIN
77+
IF NOT is_claims_admin() THEN
78+
RETURN 'error: access denied';
79+
ELSE
80+
update auth.users set raw_app_meta_data =
81+
raw_app_meta_data ||
82+
json_build_object(claim, value)::jsonb where id = uid;
83+
return 'OK';
84+
END IF;
85+
END;
86+
$$;
87+
88+
CREATE OR REPLACE FUNCTION delete_claim(uid uuid, claim text) RETURNS "text"
89+
LANGUAGE "plpgsql" SECURITY DEFINER SET search_path = public
90+
AS $$
91+
BEGIN
92+
IF NOT is_claims_admin() THEN
93+
RETURN 'error: access denied';
94+
ELSE
95+
update auth.users set raw_app_meta_data =
96+
raw_app_meta_data - claim where id = uid;
97+
return 'OK';
98+
END IF;
99+
END;
100+
$$;
101+
NOTIFY pgrst, 'reload schema';
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
CREATE OR REPLACE FUNCTION public.remove_comment(p_comment_id uuid) RETURNS boolean
2+
LANGUAGE plpgsql SECURITY DEFINER
3+
SET search_path TO 'public'
4+
AS $$
5+
declare
6+
v_author_id uuid := auth.uid();
7+
v_comment_author_id uuid;
8+
v_is_userrole_admin boolean := coalesce(get_my_claim('userrole')::text, '') = '"admin"';
9+
begin
10+
-- Auth check
11+
if v_author_id is null then
12+
raise exception 'Not authenticated' using errcode = '28000';
13+
end if;
14+
15+
-- Input validation
16+
if p_comment_id is null then
17+
raise exception 'Comment ID is required' using errcode = '22023';
18+
end if;
19+
20+
-- Check comment exists and get its author
21+
select author_id
22+
into v_comment_author_id
23+
from public.comment
24+
where id = p_comment_id;
25+
26+
if not found then
27+
raise exception 'Comment not found' using errcode = 'NO_COMMENT';
28+
end if;
29+
30+
-- Authorization: author or userrole "admin" may remove
31+
if v_comment_author_id <> v_author_id and not v_is_userrole_admin then
32+
raise exception 'Not authorized to remove this comment' using errcode = '42501';
33+
end if;
34+
35+
-- Perform the update
36+
update public.comment
37+
set removed = true,
38+
updated_at = now()
39+
where id = p_comment_id;
40+
41+
return true;
42+
end;
43+
$$;
44+
45+
NOTIFY pgrst, 'reload schema';
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
CREATE OR REPLACE FUNCTION public.update_comment(p_comment_id uuid, p_content text) RETURNS boolean
2+
LANGUAGE plpgsql SECURITY DEFINER
3+
SET search_path TO 'public'
4+
AS $$
5+
declare
6+
v_author_id uuid := auth.uid();
7+
v_comment_author_id uuid;
8+
v_removed boolean;
9+
v_is_userrole_admin boolean := coalesce(get_my_claim('userrole')::text, '') = '"admin"';
10+
begin
11+
-- Auth check
12+
if v_author_id is null then
13+
raise exception 'Not authenticated' using errcode = '28000';
14+
end if;
15+
16+
-- Input validation
17+
if p_comment_id is null then
18+
raise exception 'Comment ID is required' using errcode = '22023';
19+
end if;
20+
21+
if p_content is null or trim(p_content) = '' then
22+
raise exception 'Content is required' using errcode = '22023';
23+
end if;
24+
25+
-- Check comment exists, get author and removed status
26+
select author_id, removed
27+
into v_comment_author_id, v_removed
28+
from public.comment
29+
where id = p_comment_id;
30+
31+
if not found then
32+
raise exception 'Comment not found' using errcode = 'NO_COMMENT';
33+
end if;
34+
35+
-- Prevent updates to removed comments
36+
if v_removed then
37+
raise exception 'Cannot update a removed comment' using errcode = 'COMMENT_REMOVED';
38+
end if;
39+
40+
-- Authorization check (author or admin can update)
41+
if v_comment_author_id <> v_author_id and not v_is_userrole_admin then
42+
raise exception 'Not authorized to update this comment' using errcode = '42501';
43+
end if;
44+
45+
-- Perform the update
46+
update public.comment
47+
set content = trim(p_content),
48+
updated_at = now()
49+
where id = p_comment_id;
50+
51+
return true;
52+
end;
53+
$$;
54+
55+
NOTIFY pgrst, 'reload schema';

supabase/seed.sql

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
-- auth.users
2+
INSERT INTO "auth"."users" ("instance_id", "id", "aud", "role", "email", "encrypted_password", "email_confirmed_at", "invited_at", "confirmation_token", "confirmation_sent_at", "recovery_token", "recovery_sent_at", "email_change_token_new", "email_change", "email_change_sent_at", "last_sign_in_at", "raw_app_meta_data", "raw_user_meta_data", "is_super_admin", "created_at", "updated_at", "phone", "phone_confirmed_at", "phone_change", "phone_change_token", "phone_change_sent_at", "email_change_token_current", "email_change_confirm_status", "banned_until", "reauthentication_token", "reauthentication_sent_at", "is_sso_user", "deleted_at", "is_anonymous") VALUES
3+
('00000000-0000-0000-0000-000000000000', 'faa73ac2-bbed-40ea-a392-53baf1a946fe', 'authenticated', 'authenticated', '[email protected]', NULL, '2025-08-28 04:27:30.258836+00', NULL, '', NULL, '', NULL, '', '', NULL, '2025-08-28 07:58:16.594996+00', '{"provider": "github", "userrole": "admin", "providers": ["github"]}', '{"iss": "https://api.github.com", "sub": "9553691", "name": "Jay Lee", "email": "[email protected]", "full_name": "Jay Lee the Tester", "user_name": "Zeta611", "avatar_url": "https://avatars.githubusercontent.com/u/9553691?v=4", "provider_id": "9553691", "email_verified": true, "phone_verified": false, "preferred_username": "Zeta611"}', NULL, '2025-08-28 04:27:30.253169+00', '2025-08-28 07:58:16.597091+00', NULL, NULL, '', '', NULL, '', 0, NULL, '', NULL, false, NULL, false),
4+
('00000000-0000-0000-0000-000000000000', '98e607fc-ef95-4272-9b66-62526b576c84', 'authenticated', 'authenticated', '[email protected]', NULL, '2025-08-28 04:27:30.258836+00', NULL, '', NULL, '', NULL, '', '', NULL, '2025-08-28 04:29:30.162166+00', '{"provider": "github", "providers": ["github"]}', '{"iss": "https://api.github.com", "sub": "1000000", "name": "User A", "email": "[email protected]", "full_name": "User A", "user_name": "usera", "avatar_url": "", "provider_id": "1000000", "email_verified": true, "phone_verified": false, "preferred_username": "usera"}', NULL, '2025-08-28 04:27:30.253169+00', '2025-08-28 04:29:30.164132+00', NULL, NULL, '', '', NULL, '', 0, NULL, '', NULL, false, NULL, false);
5+
6+
-- jargon
7+
INSERT INTO "public"."jargon" ("name", "author_id", "created_at", "updated_at", "id", "slug") VALUES
8+
('user A test', '98e607fc-ef95-4272-9b66-62526b576c84', '2025-08-28 07:17:26.730353+00', '2025-08-28 07:17:26.730353+00', '6af4329d-49e6-4110-8938-f43f3cf30194', 'user-a-test'),
9+
('test', 'faa73ac2-bbed-40ea-a392-53baf1a946fe', '2025-08-28 07:17:26.730353+00', '2025-08-28 07:17:26.730353+00', 'd4ed1ffb-896f-47c5-a94e-0fc7cd7ec1f6', 'test'),
10+
('test 2', 'faa73ac2-bbed-40ea-a392-53baf1a946fe', '2025-08-28 07:17:41.53785+00', '2025-08-28 07:17:41.53785+00', 'fed56078-e4f7-41cf-b608-56fa173a82e6', 'test-2');
11+
12+
-- translation
13+
INSERT INTO "public"."translation" ("name", "author_id", "created_at", "updated_at", "id", "jargon_id", "comment_id") VALUES
14+
('테스트 2', 'faa73ac2-bbed-40ea-a392-53baf1a946fe', '2025-08-28 07:19:24.414112+00', '2025-08-28 07:19:24.414112+00', '7b1c9053-d772-4e13-bf43-1940ec66534e', 'fed56078-e4f7-41cf-b608-56fa173a82e6', '02c0cd00-3ccc-444b-bdfb-c7c25b444f7e');
15+
16+
-- comment
17+
INSERT INTO "public"."comment" ("content", "author_id", "created_at", "updated_at", "removed", "id", "jargon_id", "translation_id", "parent_id") VALUES
18+
('test의 번역이 필요해요.', 'faa73ac2-bbed-40ea-a392-53baf1a946fe', '2025-08-28 07:17:26.730353+00', '2025-08-28 07:17:26.730353+00', false, '31418f6a-fe63-49a8-a8eb-e6205171a4ac', 'd4ed1ffb-896f-47c5-a94e-0fc7cd7ec1f6', NULL, NULL),
19+
('test 2의 번역이 필요해요.', 'faa73ac2-bbed-40ea-a392-53baf1a946fe', '2025-08-28 07:17:41.53785+00', '2025-08-28 07:17:41.53785+00', false, '205e4e38-5fb1-47e0-94b2-8ab2616fcc74', '6af4329d-49e6-4110-8938-f43f3cf30194', NULL, NULL),
20+
('user a test의 번역이 필요해요.', '98e607fc-ef95-4272-9b66-62526b576c84', '2025-08-28 07:17:41.53785+00', '2025-08-28 07:17:41.53785+00', false, '14fe7f09-18d9-4194-a2e1-6ad7e0acea8b', '6af4329d-49e6-4110-8938-f43f3cf30194', NULL, NULL),
21+
('테스트 2를 제안해요.', 'faa73ac2-bbed-40ea-a392-53baf1a946fe', '2025-08-28 07:19:24.414112+00', '2025-08-28 07:19:24.414112+00', false, '02c0cd00-3ccc-444b-bdfb-c7c25b444f7e', 'fed56078-e4f7-41cf-b608-56fa173a82e6', '7b1c9053-d772-4e13-bf43-1940ec66534e', NULL);

0 commit comments

Comments
 (0)