diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..82bdfc0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,76 @@ +# Dependencies +node_modules +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Build outputs +.next +out +dist +build + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +# Keep .env.production for Docker builds +!.env.production + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Testing +coverage +.nyc_output +.jest + +# IDE and editor files +.vscode +.idea +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Git +.git +.gitignore + +# TypeScript +*.tsbuildinfo + +# Miscellaneous +README.md +LICENSE +*.md +.github + +# Cache directories +.cache +.parcel-cache +.swc + +# Husky +.husky \ No newline at end of file diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..39608d1 --- /dev/null +++ b/.env.production @@ -0,0 +1,5 @@ +NEXT_PUBLIC_BSKY_BASE_API_URL=https://api.bsky.app +NEXT_PUBLIC_BSKY_BASE=https://bsky.social +NEXT_PUBLIC_MAX_STACKED_MODALS=3 +# Use the URL specific to Docker container networking +NEXT_PUBLIC_SAFE_SKIES_API=http://backend:4000 \ No newline at end of file diff --git a/.env.sample b/.env.sample index ebde87e..9d060a4 100644 --- a/.env.sample +++ b/.env.sample @@ -6,5 +6,5 @@ NEXT_PUBLIC_MAX_STACKED_MODALS=3 NEXT_PUBLIC_CLIENT_URL=http://localhost:3000 (for local development) -NEXT_PUBLIC_SAFE_SKIES_API= < url where you host the api if running your own instance > +NEXT_PUBLIC_SAFE_SKIES_API=http://localhost:4000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e913a55 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI - Validate Frontend Changes + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run type checking + run: npm run type-check + + - name: Run linting + run: npm run lint + + - name: Run tests + run: npm run test + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image for testing + uses: docker/build-push-action@v5 + with: + context: . + target: prodrunner + load: true + tags: safe-skies-frontend:test + build-args: | + NEXT_PUBLIC_SAFE_SKIES_API=http://safe-skies-api:4000 + cache-from: type=gha + cache-to: type=gha,mode=max \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..c476feb --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,49 @@ +name: Deploy Frontend to Production + +on: + push: + branches: [main] + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Install doctl + uses: digitalocean/action-doctl@v2 + with: + token: ${{ secrets.DO_REGISTRY_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DO_REGISTRY_URL }} + tags: | + type=sha,prefix={{branch}}- + type=raw,value=latest + + - name: Log in to DigitalOcean Container Registry with short-lived credentials + run: doctl registry login --expiry-seconds 1200 + + - name: Build and push image to DigitalOcean Container Registry + uses: docker/build-push-action@v5 + with: + context: . + target: prodrunner + push: true + tags: ${{ steps.meta.outputs.tags }} + build-args: | + NEXT_PUBLIC_SAFE_SKIES_API=http://safe-skies-api:4000 + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7a9d124 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +# Node LTS +ARG NODE_VERSION=22.14.0 + +FROM node:${NODE_VERSION}-alpine AS base +WORKDIR /usr/src/app +EXPOSE 3000 + +FROM base AS dev +RUN --mount=type=bind,source=package.json,target=package.json \ + --mount=type=bind,source=package-lock.json,target=package-lock.json \ + --mount=type=cache,target=/home/node/.npm \ + npm ci --include=dev + +USER node +COPY . . +CMD npm run dev + +FROM base AS prodbuilder + +ARG NEXT_PUBLIC_SAFE_SKIES_API + +RUN --mount=type=bind,source=package.json,target=package.json \ + --mount=type=bind,source=package-lock.json,target=package-lock.json \ + --mount=type=cache,target=/home/node/.npm \ + npm ci + +RUN chown -R node:node /usr/src/app + +USER node +COPY --chown=node:node . . +RUN npm run build + +FROM base AS prodrunner +USER node +COPY --from=prodbuilder --chown=node:node /usr/src/app/.next/standalone ./ +COPY --from=prodbuilder --chown=node:node /usr/src/app/.next/static ./.next/static +COPY --from=prodbuilder --chown=node:node /usr/src/app/public ./public + +CMD ["node", "server.js"] \ No newline at end of file diff --git a/app/api/moderation/emit-event/route.ts b/app/api/moderation/emit-event/route.ts new file mode 100644 index 0000000..4a9ed57 --- /dev/null +++ b/app/api/moderation/emit-event/route.ts @@ -0,0 +1,71 @@ +import { fetchWithAuth } from '@/lib/api'; +import { NextResponse } from 'next/server'; + +export async function POST(request: Request) { + try { + const body = await request.json(); + + // Validate required fields + if (!body.did) { + return NextResponse.json( + { error: 'Missing required field: did' }, + { status: 400 } + ); + } + + if (!body.eventType) { + return NextResponse.json( + { error: 'Missing required field: eventType' }, + { status: 400 } + ); + } + + // Forward to backend + const response = await fetchWithAuth('/api/moderation/emit-event', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response) { + return NextResponse.json( + { error: 'Failed to emit moderation event' }, + { status: 500 } + ); + } + + // Parse response safely + const rawText = await response.text(); + + let data; + try { + data = JSON.parse(rawText); + } catch (jsonError) { + return NextResponse.json( + { + error: jsonError instanceof Error + ? jsonError.message + : 'Invalid JSON returned from backend', + raw: rawText, + }, + { status: response.status } + ); + } + + if (!response.ok) { + return NextResponse.json( + { error: data.error || 'Failed to emit moderation event' }, + { status: response.status } + ); + } + + return NextResponse.json(data); + } catch (error: unknown) { + return NextResponse.json( + { + error: error instanceof Error ? error.message : 'Internal server error', + }, + { status: 500 } + ); + } +} diff --git a/app/api/moderation/escalated-users/route.ts b/app/api/moderation/escalated-users/route.ts new file mode 100644 index 0000000..5f459d3 --- /dev/null +++ b/app/api/moderation/escalated-users/route.ts @@ -0,0 +1,57 @@ +import { fetchWithAuth } from '@/lib/api'; +import { NextResponse } from 'next/server'; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const limit = searchParams.get('limit') || '20'; + const cursor = searchParams.get('cursor'); + + const url = cursor + ? `/api/moderation/escalated-users?limit=${limit}&cursor=${encodeURIComponent(cursor)}` + : `/api/moderation/escalated-users?limit=${limit}`; + + const response = await fetchWithAuth(url); + + if (!response) { + return NextResponse.json( + { error: 'Failed to fetch escalated users' }, + { status: 500 } + ); + } + + const rawText = await response.text(); + + let data; + try { + data = JSON.parse(rawText); + } catch (jsonError) { + return NextResponse.json( + { + error: + jsonError instanceof Error + ? jsonError.message + : 'Invalid JSON returned from backend', + raw: rawText, + }, + { status: response.status } + ); + } + + if (!response.ok) { + return NextResponse.json( + { error: data.error || 'Failed to fetch escalated users' }, + { status: response.status } + ); + } + + return NextResponse.json(data); + } catch (error: unknown) { + return NextResponse.json( + { + error: error instanceof Error ? error.message : 'Failed to fetch escalated users', + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/moderation/profile/[did]/route.ts b/app/api/moderation/profile/[did]/route.ts new file mode 100644 index 0000000..03bb8d0 --- /dev/null +++ b/app/api/moderation/profile/[did]/route.ts @@ -0,0 +1,63 @@ +import { fetchWithAuth } from '@/lib/api'; +import { NextResponse } from 'next/server'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ did: string }> } +) { + try { + const { did } = await params; + + if (!did) { + return NextResponse.json( + { error: 'Missing required parameter: did' }, + { status: 400 } + ); + } + + const response = await fetchWithAuth( + `/api/moderation/profile/${encodeURIComponent(did)}` + ); + + if (!response) { + return NextResponse.json( + { error: 'Failed to fetch profile moderation data' }, + { status: 500 } + ); + } + + const rawText = await response.text(); + + let data; + try { + data = JSON.parse(rawText); + } catch (jsonError) { + return NextResponse.json( + { + error: + jsonError instanceof Error + ? jsonError.message + : 'Invalid JSON returned from backend', + raw: rawText, + }, + { status: response.status } + ); + } + + if (!response.ok) { + return NextResponse.json( + { error: data.error || 'Failed to fetch profile moderation data' }, + { status: response.status } + ); + } + + return NextResponse.json(data); + } catch (error: unknown) { + return NextResponse.json( + { + error: error instanceof Error ? error.message : 'Failed to fetch profile moderation data', + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/moderation/report-options/route.ts b/app/api/moderation/report-options/route.ts new file mode 100644 index 0000000..e5d00ee --- /dev/null +++ b/app/api/moderation/report-options/route.ts @@ -0,0 +1,49 @@ +import { fetchWithAuth } from '@/lib/api'; +import { NextResponse } from 'next/server'; + +export async function GET() { + try { + const response = await fetchWithAuth('/api/moderation/report-options'); + + if (!response) { + return NextResponse.json( + { error: 'Failed to fetch report options' }, + { status: 500 } + ); + } + + const rawText = await response.text(); + + let data; + try { + data = JSON.parse(rawText); + } catch (jsonError) { + return NextResponse.json( + { + error: + jsonError instanceof Error + ? jsonError.message + : 'Invalid JSON returned from backend', + raw: rawText, + }, + { status: response.status } + ); + } + + if (!response.ok) { + return NextResponse.json( + { error: data.error || 'Failed to fetch report options' }, + { status: response.status } + ); + } + + return NextResponse.json(data); + } catch (error: unknown) { + return NextResponse.json( + { + error: error instanceof Error ? error.message : 'Internal server error', + }, + { status: 500 } + ); + } +} diff --git a/app/users/page.tsx b/app/users/page.tsx index bbdfd07..c0d0349 100644 --- a/app/users/page.tsx +++ b/app/users/page.tsx @@ -25,19 +25,9 @@ export default async function Page() { ); } - // For now, we only support Blacksky feed - const blackskyRole = profile.rolesByFeed.find(role => - role.displayName.toLowerCase().includes('blacksky') - )! - return (
-
-

User Management

-
- +
); } \ No newline at end of file diff --git a/components/escalated-users-list/index.tsx b/components/escalated-users-list/index.tsx new file mode 100644 index 0000000..91f01b0 --- /dev/null +++ b/components/escalated-users-list/index.tsx @@ -0,0 +1,142 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { fetchEscalatedUsers } from '@/repos/moderation'; +import { LoadingSpinner } from '@/components/loading-spinner'; +import { UserCard } from '@/components/user-card'; +import { Button } from '@/components/button'; +import { VisualIntent } from '@/enums/styles'; +import { EscalatedItem } from '@/lib/types/moderation'; + +interface EscalatedUsersListProps { + onUserSelect: (item: EscalatedItem) => void; +} + +export const EscalatedUsersList = ({ onUserSelect }: EscalatedUsersListProps) => { + const [state, setState] = useState({ + items: [] as EscalatedItem[], + loading: true, + error: null as string | null, + }); + const [cursor, setCursor] = useState(null); + const [hasMore, setHasMore] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + + useEffect(() => { + const loadEscalatedItems = async () => { + try { + setState(prev => ({ ...prev, loading: true, error: null })); + const response = await fetchEscalatedUsers(); + setState(prev => ({ + ...prev, + items: response.items, + loading: false, + })); + setCursor(response.cursor || null); + setHasMore(response.hasMore ?? !!response.cursor); + } catch (error) { + setState(prev => ({ + ...prev, + loading: false, + error: error instanceof Error ? error.message : 'Failed to load escalated items', + })); + } + }; + + loadEscalatedItems(); + }, []); + + const loadMore = async () => { + if (!cursor || loadingMore) return; + setLoadingMore(true); + try { + const response = await fetchEscalatedUsers(20, cursor); + setState(prev => ({ + ...prev, + items: [...prev.items, ...response.items], + })); + setCursor(response.cursor || null); + setHasMore(response.hasMore ?? !!response.cursor); + } catch (error) { + console.error('Error loading more items:', error); + } finally { + setLoadingMore(false); + } + }; + + if (state.loading) { + return ( +
+

+ Escalated Reports +

+
+ + Loading reported users... +
+
+ ); + } + + if (state.error) { + return ( +
+

+ Escalated Reports +

+
+

Error loading escalated reports

+

{state.error}

+
+
+ ); + } + + if (state.items.length === 0) { + return ( +
+

+ Escalated Reports +

+
+

No items currently in the escalation queue

+
+
+ ); + } + + return ( +
+

+ Escalated Reports ({state.items.length}) +

+
+ {state.items.map((item) => ( + onUserSelect(item)} + actionText="Click to review" + badge={{ + text: item.type === 'post' ? 'Reported Post' : 'Reported', + className: item.type === 'post' + ? 'bg-orange-100 text-orange-800' + : 'bg-red-100 text-red-800' + }} + /> + ))} +
+ {hasMore && ( +
+ +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/components/modals/user-moderation-modal.tsx b/components/modals/user-moderation-modal.tsx index 4e9a410..827f3b1 100644 --- a/components/modals/user-moderation-modal.tsx +++ b/components/modals/user-moderation-modal.tsx @@ -7,11 +7,20 @@ import { Button } from '@/components/button'; import { OptimizedImage } from '@/components/optimized-image'; import { VisualIntent } from '@/enums/styles'; import { ProfileViewBasic } from '@atproto/api/dist/client/types/app/bsky/actor/defs'; -import { checkUserBanStatus, banUser, unbanUser, BannedFromTV, checkUserMuteStatus, muteUser, unmuteUser } from '@/repos/moderation'; +import { checkUserBanStatus, banUser, unbanUser, BannedFromTV, checkUserMuteStatus, muteUser, unmuteUser, fetchProfileModerationData, emitModerationEvent } from '@/repos/moderation'; import { ToastContext } from '@/contexts/toast-context'; +import { ProfileModerationResponse, ModerationEventType, EscalatedItem, EscalatedPostItem } from '@/lib/types/moderation'; +import { getPostUrl } from '@/components/post/utils'; + +type UserLike = { + did: string; + handle?: string; + displayName?: string; + avatar?: string | null; +}; interface UserModerationModalProps { - user: ProfileViewBasic | null; + user: ProfileViewBasic | EscalatedItem | UserLike | null; onClose: () => void; } @@ -24,14 +33,21 @@ export const UserModerationModal = ({ const [banStatus, setBanStatus] = useState<{ isBanned: boolean; banInfo?: BannedFromTV; + error?: string; }>({ isBanned: false, }); - const [muteStatus, setMuteStatus] = useState({ + const [muteStatus, setMuteStatus] = useState<{ + isMuted: boolean; + error?: string; + }>({ isMuted: false, }); + const [moderationData, setModerationData] = useState(null); + const [loadingModerationData, setLoadingModerationData] = useState(false); + const [banForm, setBanForm] = useState({ showForm: false, reason: '', @@ -39,9 +55,54 @@ export const UserModerationModal = ({ newTag: '', }); + // Ozone Actions state + const [ozoneAction, setOzoneAction] = useState<{ + type: ModerationEventType; + loading: boolean; + comment: string; + // Takedown specific + durationInHours: string; + // Label specific + createLabelVals: string[]; + negateLabelVals: string[]; + newLabel: string; + // Tag specific + addTags: string[]; + removeTags: string[]; + newTag: string; + // Comment specific + sticky: boolean; + }>({ + type: 'acknowledge', + loading: false, + comment: '', + durationInHours: '', + createLabelVals: [], + negateLabelVals: [], + newLabel: '', + addTags: [], + removeTags: [], + newTag: '', + sticky: false, + }); + // Check ban and mute status when modal opens useEffect(() => { if (user?.did) { + // Fetch enriched profile data + setLoadingModerationData(true); + fetchProfileModerationData(user.did) + .then((data) => { + setModerationData(data); + }) + .catch((error) => { + console.error('Failed to fetch moderation data:', error); + setModerationData({ error: 'Failed to load moderation data. Try refreshing.' } as ProfileModerationResponse); + }) + .finally(() => { + setLoadingModerationData(false); + }); + // Check ban status checkUserBanStatus(user.did) .then((result) => { @@ -52,11 +113,7 @@ export const UserModerationModal = ({ }) .catch((error) => { console.error('Failed to check ban status:', error); - toastContext?.toast({ - title: 'Error', - message: 'Failed to check user ban status', - intent: VisualIntent.Error, - }); + setBanStatus({ isBanned: false, error: 'Failed to check ban status. Try refreshing.' }); }); // Check mute status @@ -68,14 +125,10 @@ export const UserModerationModal = ({ }) .catch((error) => { console.error('Failed to check mute status:', error); - toastContext?.toast({ - title: 'Error', - message: 'Failed to check user mute status', - intent: VisualIntent.Error, - }); + setMuteStatus({ isMuted: false, error: 'Failed to check mute status. Try refreshing.' }); }); } - }, [user?.did, toastContext]); + }, [user?.did]); if (!user) { return null; @@ -226,16 +279,147 @@ export const UserModerationModal = ({ } }; + // Ozone Action Handlers + const resetOzoneAction = () => { + setOzoneAction({ + type: 'acknowledge', + loading: false, + comment: '', + durationInHours: '', + createLabelVals: [], + negateLabelVals: [], + newLabel: '', + addTags: [], + removeTags: [], + newTag: '', + sticky: false, + }); + }; + + const handleOzoneTypeChange = (type: ModerationEventType) => { + setOzoneAction({ + type, + loading: false, + comment: '', + durationInHours: '', + createLabelVals: [], + negateLabelVals: [], + newLabel: '', + addTags: [], + removeTags: [], + newTag: '', + sticky: false, + }); + }; + + const refreshModerationData = async () => { + try { + const data = await fetchProfileModerationData(user.did); + setModerationData(data); + } catch (error) { + console.error('Failed to refresh moderation data:', error); + } + }; + + const handleOzoneSubmit = async () => { + if (!ozoneAction.type) return; + + setOzoneAction(prev => ({ ...prev, loading: true })); + + try { + const eventParams: Record = {}; + + switch (ozoneAction.type) { + case 'takedown': + if (ozoneAction.comment) eventParams.comment = ozoneAction.comment; + if (ozoneAction.durationInHours) eventParams.durationInHours = parseInt(ozoneAction.durationInHours); + break; + case 'reverseTakedown': + case 'acknowledge': + case 'escalate': + if (ozoneAction.comment) eventParams.comment = ozoneAction.comment; + break; + case 'comment': + eventParams.comment = ozoneAction.comment; + if (ozoneAction.sticky) eventParams.sticky = true; + break; + case 'label': + eventParams.createLabelVals = ozoneAction.createLabelVals; + eventParams.negateLabelVals = ozoneAction.negateLabelVals; + if (ozoneAction.comment) eventParams.comment = ozoneAction.comment; + break; + case 'tag': + eventParams.add = ozoneAction.addTags; + eventParams.remove = ozoneAction.removeTags; + if (ozoneAction.comment) eventParams.comment = ozoneAction.comment; + break; + } + + // Check if this is an escalated post and pass subject info for post-level moderation + const isEscalatedPost = 'type' in user && user.type === 'post'; + const subjectUri = isEscalatedPost ? (user as EscalatedPostItem).postUri : undefined; + const subjectCid = isEscalatedPost ? (user as EscalatedPostItem).postCid : undefined; + + const result = await emitModerationEvent(user.did, ozoneAction.type, eventParams, subjectUri, subjectCid); + + toastContext?.toast({ + title: 'Success', + message: result.message || `Successfully emitted ${ozoneAction.type} event`, + intent: VisualIntent.Success, + }); + + resetOzoneAction(); + await refreshModerationData(); + } catch (error) { + console.error('Failed to emit moderation event:', error); + toastContext?.toast({ + title: 'Error', + message: error instanceof Error ? error.message : 'Failed to emit moderation event', + intent: VisualIntent.Error, + }); + } finally { + setOzoneAction(prev => ({ ...prev, loading: false })); + } + }; + + const getActionLabel = (type: ModerationEventType): string => { + const labels: Record = { + takedown: 'Takedown', + reverseTakedown: 'Reverse Takedown', + acknowledge: 'Acknowledge', + escalate: 'Escalate', + comment: 'Add Comment', + label: 'Add Labels', + tag: 'Add Tags', + }; + return labels[type]; + }; + + const canSubmitOzoneAction = (): boolean => { + if (!ozoneAction.type || ozoneAction.loading) return false; + + switch (ozoneAction.type) { + case 'comment': + return ozoneAction.comment.trim().length > 0; + case 'label': + return ozoneAction.createLabelVals.length > 0 || ozoneAction.negateLabelVals.length > 0; + case 'tag': + return ozoneAction.addTags.length > 0 || ozoneAction.removeTags.length > 0; + default: + return true; + } + }; + return ( -
- {/* User Profile Section */} -
+
+ {/* User Profile Section - Full Width Top */} +
{user.avatar ? ( - {(user.displayName || user.handle).charAt(0).toUpperCase()} + {(user.displayName || user.handle || '?').charAt(0).toUpperCase()}
)}

- {user.displayName || user.handle} + {user.displayName || user.handle || 'Unknown User'}

-

@{user.handle}

+ {user.handle &&

@{user.handle}

}

{user.did}

+ {'type' in user && user.type === 'post' && 'postUri' in user && ( + + View Reported Post → + + )}
+ {/* Two Column Layout */} +
+ {/* Left Column - Actions */} +
+ {/* Moderation Actions */} + {!banForm.showForm && ( +
+

Quick Actions

+ {(banStatus.error || muteStatus.error) && ( +
+ {banStatus.error && ( +

{banStatus.error}

+ )} + {muteStatus.error && ( +

{muteStatus.error}

+ )} +
+ )} +
+ + +
+
+ )} + + {/* Ban Form Section */} + {banForm.showForm && ( +
+

Ban User Details

+
+ +