diff --git a/src/components/TabView.tsx b/src/components/TabView.tsx index a6296e1..05edc21 100644 --- a/src/components/TabView.tsx +++ b/src/components/TabView.tsx @@ -1,11 +1,16 @@ import { useState } from "react"; import InviteCodes from "./features/invites/InviteCodes"; +import UserList from "./features/users/UserList"; const TABS = { invites: { component: InviteCodes, label: "Invite Codes", }, + users: { + component: UserList, + label: "User List", + }, } as const; type TabKey = keyof typeof TABS; diff --git a/src/components/features/users/UserList.tsx b/src/components/features/users/UserList.tsx new file mode 100644 index 0000000..fb6f73f --- /dev/null +++ b/src/components/features/users/UserList.tsx @@ -0,0 +1,396 @@ +import { useState, useEffect } from "react"; +import { Settings } from "../../../lib/settings"; +import { useRefresh } from "../../../lib/RefreshContext"; + +interface User { + did: string; + displayName?: string; + description?: string; + avatar?: { + ref: { + $link: string; + }; + }; + createdAt: string; +} + +interface RepoResponse { + cursor: string; + repos: { + did: string; + head: string; + rev: string; + active: boolean; + }[]; +} + +function getAuthHeader(adminPassword: string) { + const authString = `admin:${adminPassword}`; + const base64Auth = btoa(authString); + return `Basic ${base64Auth}`; +} + +export default function UserList() { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const { lastUpdate } = useRefresh(); + const [deleteConfirmation, setDeleteConfirmation] = useState<{ + show: boolean; + user: User | null; + }>({ + show: false, + user: null, + }); + + const resetState = () => { + setUsers([]); + setError(null); + setLoading(false); + }; + + const fetchUserProfile = async (did: string): Promise => { + try { + const baseUrl = Settings.getServiceUrl(); + if (!baseUrl) throw new Error("Service URL not configured"); + + const adminPassword = Settings.getAdminPassword(); + if (!adminPassword) throw new Error("Admin password not configured"); + + const headers = { + Authorization: getAuthHeader(adminPassword), + "Content-Type": "application/json", + }; + + const response = await fetch( + `${baseUrl}/xrpc/com.atproto.repo.getRecord?collection=app.bsky.actor.profile&repo=${did}&rkey=self`, + { headers }, + ); + + if (!response.ok) { + throw new Error(`Failed to fetch profile: ${response.status}`); + } + + const data = await response.json(); + return { + did, + displayName: data.value.displayName, + description: data.value.description, + avatar: data.value.avatar, + createdAt: data.value.createdAt, + }; + } catch (error) { + console.error(`Error fetching profile for ${did}:`, error); + return null; + } + }; + + const deleteUser = async (did: string) => { + try { + setLoading(true); + setError(null); + + const baseUrl = Settings.getServiceUrl(); + if (!baseUrl) throw new Error("Service URL not configured"); + + const adminPassword = Settings.getAdminPassword(); + if (!adminPassword) throw new Error("Admin password not configured"); + + const headers = { + Authorization: getAuthHeader(adminPassword), + "Content-Type": "application/json", + }; + + const response = await fetch( + `${baseUrl}/xrpc/com.atproto.admin.deleteAccount`, + { + method: "POST", + headers, + body: JSON.stringify({ did }), + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Error response:", errorText); + throw new Error( + `Failed to delete user: ${response.status} ${response.statusText}`, + ); + } + + await fetchUsers(); + } catch (err) { + console.error("Delete error:", err); + setError(err instanceof Error ? err.message : "Failed to delete user"); + } finally { + setLoading(false); + } + }; + + const fetchUsers = async () => { + try { + setLoading(true); + setError(null); + + const baseUrl = Settings.getServiceUrl(); + if (!baseUrl) throw new Error("Service URL not configured"); + + const adminPassword = Settings.getAdminPassword(); + if (!adminPassword) throw new Error("Admin password not configured"); + + const headers = { + Authorization: getAuthHeader(adminPassword), + "Content-Type": "application/json", + }; + + const params = new URLSearchParams({ + limit: "100", + }); + + const response = await fetch( + `${baseUrl}/xrpc/com.atproto.sync.listRepos?${params}`, + { headers }, + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Error response:", errorText); + resetState(); + throw new Error( + `Failed to fetch users: ${response.status} ${response.statusText}`, + ); + } + + const data: RepoResponse = await response.json(); + const userProfiles = await Promise.all( + data.repos.map((repo) => fetchUserProfile(repo.did)), + ); + + setUsers( + userProfiles + .filter((user): user is User => user !== null) + .sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ), + ); + } catch (err) { + console.error("Fetch error:", err); + resetState(); + setError(err instanceof Error ? err.message : "Failed to fetch users"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + return () => { + resetState(); + }; + }, []); + + useEffect(() => { + if (typeof window !== "undefined") { + fetchUsers(); + } + }, [lastUpdate]); + + if (typeof window === "undefined") { + return
; + } + + const serviceUrl = Settings.getServiceUrl(); + const adminPassword = Settings.getAdminPassword(); + + if (!serviceUrl || !adminPassword) { + return ( +
+
+
+ + + +
+

Settings Required

+
+ Please configure the service URL and admin password in settings. +
+
+
+
+
+ ); + } + + return ( +
+
+
+

BlueSky Users

+ +
+ + {/* Confirmation Modal */} + {deleteConfirmation.show && deleteConfirmation.user && ( + +
+

Confirm Delete

+

+ Are you sure you want to delete the account for{" "} + + {deleteConfirmation.user.displayName || "Anonymous"} + + ? +

+

+ DID: {deleteConfirmation.user.did} +

+
+ + +
+
+
+ +
+
+ )} + + {error && ( +
+ + + +
+

Error!

+
{error}
+
+ +
+ )} + + {loading ? ( +
+ +
+ ) : ( +
+ {users.map((user) => ( +
+
+
+ {user.avatar ? ( + {user.displayName + ) : ( +
+
+ + {user.displayName?.[0] || "?"} + +
+
+ )} +
+

+ {user.displayName || "Anonymous"} +

+
+ {user.did} +
+ {user.description && ( +

+ {user.description} +

+ )} +
+
+ +
+
+
+
+ Joined: {new Date(user.createdAt).toLocaleDateString()} +
+
+
+
+ ))} + {!loading && users.length === 0 && ( +
+
No users found
+
+ )} +
+ )} +
+
+ ); +}