User Management
All checks were successful
Docker Deploy / build-and-push (push) Successful in 1m26s

This commit is contained in:
Atridad Lahiji 2025-01-26 18:37:39 -06:00
parent 8d59f250dc
commit be36dfc045
Signed by: atridad
SSH key fingerprint: SHA256:LGomp8Opq0jz+7kbwNcdfTcuaLRb5Nh0k5AchDDb438
2 changed files with 401 additions and 0 deletions

View file

@ -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;

View file

@ -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<User[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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<User | null> => {
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 <div></div>;
}
const serviceUrl = Settings.getServiceUrl();
const adminPassword = Settings.getAdminPassword();
if (!serviceUrl || !adminPassword) {
return (
<div className="card bg-base-100 shadow-xl">
<div className="card-body">
<div className="alert alert-warning">
<svg
xmlns="http://www.w3.org/2000/svg"
className="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<div>
<h3 className="font-bold">Settings Required</h3>
<div className="text-xs">
Please configure the service URL and admin password in settings.
</div>
</div>
</div>
</div>
</div>
);
}
return (
<div className="card bg-base-100 shadow-xl">
<div className="card-body">
<div className="flex justify-between items-center mb-4">
<h1 className="card-title text-2xl">BlueSky Users</h1>
<button
onClick={fetchUsers}
disabled={loading}
className="btn btn-primary"
>
Refresh Users
</button>
</div>
{/* Confirmation Modal */}
{deleteConfirmation.show && deleteConfirmation.user && (
<dialog className="modal modal-open">
<div className="modal-box">
<h3 className="font-bold text-lg text-error">Confirm Delete</h3>
<p className="py-4">
Are you sure you want to delete the account for{" "}
<span className="font-semibold">
{deleteConfirmation.user.displayName || "Anonymous"}
</span>
?
</p>
<p className="text-sm text-base-content/70 mb-4">
DID: {deleteConfirmation.user.did}
</p>
<div className="modal-action">
<button
className="btn"
onClick={() =>
setDeleteConfirmation({ show: false, user: null })
}
>
Cancel
</button>
<button
className="btn btn-error"
onClick={async () => {
if (deleteConfirmation.user) {
await deleteUser(deleteConfirmation.user.did);
setDeleteConfirmation({ show: false, user: null });
}
}}
>
Delete Account
</button>
</div>
</div>
<form method="dialog" className="modal-backdrop">
<button
onClick={() =>
setDeleteConfirmation({ show: false, user: null })
}
>
close
</button>
</form>
</dialog>
)}
{error && (
<div className="alert alert-error">
<svg
xmlns="http://www.w3.org/2000/svg"
className="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div>
<h3 className="font-bold">Error!</h3>
<div className="text-xs">{error}</div>
</div>
<button onClick={fetchUsers} className="btn btn-sm">
Retry
</button>
</div>
)}
{loading ? (
<div className="flex justify-center p-8">
<span className="loading loading-spinner loading-lg"></span>
</div>
) : (
<div className="space-y-4">
{users.map((user) => (
<div
key={user.did}
className="card bg-base-200 hover:shadow-md transition-shadow"
>
<div className="card-body">
<div className="flex items-start gap-4">
{user.avatar ? (
<img
src={`${serviceUrl}/xrpc/com.atproto.sync.getBlob?did=${
user.did
}&cid=${user.avatar.ref.$link}`}
alt={user.displayName || "User avatar"}
className="w-16 h-16 rounded-full object-cover"
/>
) : (
<div className="avatar placeholder">
<div className="bg-neutral-focus text-neutral-content rounded-full w-16">
<span className="text-xl">
{user.displayName?.[0] || "?"}
</span>
</div>
</div>
)}
<div className="flex-1">
<h2 className="text-lg font-semibold">
{user.displayName || "Anonymous"}
</h2>
<div className="font-mono text-sm opacity-70 mt-1">
{user.did}
</div>
{user.description && (
<p className="text-sm mt-2 opacity-70 line-clamp-2">
{user.description}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<button
onClick={() =>
setDeleteConfirmation({ show: true, user })
}
className="btn btn-sm btn-error"
disabled={loading}
>
Delete
</button>
</div>
</div>
<div className="flex flex-wrap gap-2 mt-2">
<div className="badge badge-ghost">
Joined: {new Date(user.createdAt).toLocaleDateString()}
</div>
</div>
</div>
</div>
))}
{!loading && users.length === 0 && (
<div className="text-center p-8 bg-base-200 rounded-box">
<div className="text-base-content/70">No users found</div>
</div>
)}
</div>
)}
</div>
</div>
);
}