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 { useState } from "react";
|
||||||
import InviteCodes from "./features/invites/InviteCodes";
|
import InviteCodes from "./features/invites/InviteCodes";
|
||||||
|
import UserList from "./features/users/UserList";
|
||||||
|
|
||||||
const TABS = {
|
const TABS = {
|
||||||
invites: {
|
invites: {
|
||||||
component: InviteCodes,
|
component: InviteCodes,
|
||||||
label: "Invite Codes",
|
label: "Invite Codes",
|
||||||
},
|
},
|
||||||
|
users: {
|
||||||
|
component: UserList,
|
||||||
|
label: "User List",
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type TabKey = keyof typeof TABS;
|
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