User Management
All checks were successful
Docker Deploy / build-and-push (push) Successful in 1m26s
All checks were successful
Docker Deploy / build-and-push (push) Successful in 1m26s
This commit is contained in:
parent
8d59f250dc
commit
be36dfc045
2 changed files with 401 additions and 0 deletions
|
@ -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;
|
||||
|
|
396
src/components/features/users/UserList.tsx
Normal file
396
src/components/features/users/UserList.tsx
Normal 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>
|
||||
);
|
||||
}
|
Loading…
Add table
Reference in a new issue