aethex-forge/client/components/admin/AdminMemberManager.tsx
2025-10-14 02:48:15 +00:00

663 lines
22 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
import {
aethexRoleService,
aethexUserService,
type AethexUserProfile,
} from "@/lib/aethex-database-adapter";
import { aethexToast } from "@/lib/aethex-toast";
import { cn } from "@/lib/utils";
import {
BadgeCheck,
Loader2,
Plus,
RefreshCw,
ShieldCheck,
UserCog,
} from "lucide-react";
const roleOptions = [
"owner",
"admin",
"founder",
"moderator",
"creator",
"mentor",
"staff",
"member",
];
const experienceOptions = ["beginner", "intermediate", "advanced", "expert"];
const userTypeOptions = [
"game_developer",
"client",
"community_member",
"customer",
];
interface AdminMemberManagerProps {
profiles: AethexUserProfile[];
selectedId: string | null;
onSelectedIdChange: (id: string) => void;
onRefresh: () => Promise<void>;
ownerEmail: string;
}
interface ProfileDraft {
full_name: string;
location: string;
bio: string;
experience_level: string;
user_type: string;
level: string;
total_xp: string;
loyalty_points: string;
}
const buildProfileDraft = (profile: AethexUserProfile): ProfileDraft => ({
full_name: profile.full_name ?? "",
location: profile.location ?? "",
bio: profile.bio ?? "",
experience_level: profile.experience_level ?? "beginner",
user_type: profile.user_type ?? "game_developer",
level: profile.level != null ? String(profile.level) : "",
total_xp: profile.total_xp != null ? String(profile.total_xp) : "",
loyalty_points:
(profile as any).loyalty_points != null
? String((profile as any).loyalty_points)
: "0",
});
const ensureOwnerRoles = (
roles: string[],
profile: AethexUserProfile | null,
ownerEmail: string,
) => {
if (!profile) return roles;
if ((profile.email ?? "").toLowerCase() !== ownerEmail.toLowerCase()) {
return roles;
}
const required = new Set(["owner", "admin", "founder", ...roles]);
return Array.from(required);
};
const normalizeRoles = (roles: string[]): string[] => {
const normalized = roles
.map((role) => role.trim().toLowerCase())
.filter((role) => role.length > 0);
const unique = Array.from(new Set(normalized));
return unique.length ? unique : ["member"];
};
const AdminMemberManager = ({
profiles,
selectedId,
onSelectedIdChange,
onRefresh,
ownerEmail,
}: AdminMemberManagerProps) => {
const [roles, setRoles] = useState<string[]>([]);
const [loadingRoles, setLoadingRoles] = useState(false);
const [savingRoles, setSavingRoles] = useState(false);
const [savingProfile, setSavingProfile] = useState(false);
const [customRole, setCustomRole] = useState("");
const [query, setQuery] = useState("");
const [profileDraft, setProfileDraft] = useState<ProfileDraft | null>(null);
const selectedProfile = useMemo(
() => profiles.find((profile) => profile.id === selectedId) ?? null,
[profiles, selectedId],
);
useEffect(() => {
if (!selectedId && profiles.length) {
onSelectedIdChange(profiles[0].id);
}
}, [profiles, selectedId, onSelectedIdChange]);
const loadRoles = useCallback(async (id: string) => {
setLoadingRoles(true);
try {
const fetched = await aethexRoleService.getUserRoles(id);
setRoles(normalizeRoles(fetched));
} catch (error) {
console.warn("Failed to load user roles", error);
setRoles(["member"]);
} finally {
setLoadingRoles(false);
}
}, []);
useEffect(() => {
if (selectedProfile) {
setProfileDraft(buildProfileDraft(selectedProfile));
loadRoles(selectedProfile.id).catch(() => undefined);
} else {
setProfileDraft(null);
setRoles([]);
}
}, [selectedProfile, loadRoles]);
const filteredProfiles = useMemo(() => {
const value = query.trim().toLowerCase();
if (!value) return profiles;
return profiles.filter((profile) => {
const haystack = [
profile.full_name,
profile.username,
profile.email,
profile.bio,
profile.location,
profile.role,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return haystack.includes(value);
});
}, [profiles, query]);
const handleRoleToggle = (role: string) => {
setRoles((prev) =>
prev.includes(role.toLowerCase())
? prev.filter((item) => item !== role.toLowerCase())
: [...prev, role.toLowerCase()],
);
};
const addCustomRole = () => {
const value = customRole.trim().toLowerCase();
if (!value) return;
setRoles((prev) => (prev.includes(value) ? prev : [...prev, value]));
setCustomRole("");
};
const saveRoles = async () => {
if (!selectedProfile) return;
setSavingRoles(true);
try {
const enforced = ensureOwnerRoles(
normalizeRoles(roles),
selectedProfile,
ownerEmail,
);
await aethexRoleService.setUserRoles(selectedProfile.id, enforced);
setRoles(enforced);
aethexToast.success({
title: "Roles updated",
description: `${selectedProfile.full_name ?? selectedProfile.email ?? "Member"} now has ${enforced.join(", ")}.`,
});
} catch (error: any) {
console.error("Failed to set user roles", error);
aethexToast.error({
title: "Role update failed",
description:
error?.message || "Unable to update roles. Check Supabase policies.",
});
} finally {
setSavingRoles(false);
}
};
const saveProfile = async () => {
if (!selectedProfile || !profileDraft) return;
setSavingProfile(true);
try {
const updates: Partial<AethexUserProfile> = {
full_name: profileDraft.full_name.trim() || null,
location: profileDraft.location.trim() || null,
bio: profileDraft.bio.trim() || null,
experience_level: profileDraft.experience_level as any,
user_type: profileDraft.user_type as any,
};
if (profileDraft.level.trim().length) {
updates.level = Number(profileDraft.level) || 0;
}
if (profileDraft.total_xp.trim().length) {
updates.total_xp = Number(profileDraft.total_xp) || 0;
}
if (profileDraft.loyalty_points.trim().length) {
(updates as any).loyalty_points =
Number(profileDraft.loyalty_points) || 0;
}
await aethexUserService.updateProfile(selectedProfile.id, updates);
aethexToast.success({
title: "Profile updated",
description: `${selectedProfile.full_name ?? selectedProfile.email ?? "Member"} profile saved.`,
});
await onRefresh();
} catch (error: any) {
console.error("Failed to update profile", error);
aethexToast.error({
title: "Profile update failed",
description:
error?.message ||
"Supabase rejected the update. Review payload and RLS policies.",
});
} finally {
setSavingProfile(false);
}
};
const resetDraft = () => {
if (!selectedProfile) return;
setProfileDraft(buildProfileDraft(selectedProfile));
};
return (
<div className="grid gap-6 lg:grid-cols-[minmax(0,320px)_1fr]">
<Card className="bg-card/60 border-border/40 backdrop-blur">
<CardHeader className="gap-3">
<div className="flex items-center justify-between gap-2">
<div>
<CardTitle>Directory</CardTitle>
<CardDescription>
Search and select members to administer.
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
onClick={async () => {
await onRefresh();
if (selectedProfile) {
loadRoles(selectedProfile.id).catch(() => undefined);
}
}}
>
<RefreshCw className="mr-2 h-4 w-4" /> Refresh
</Button>
</div>
<Input
placeholder="Search by name, email, or role"
value={query}
onChange={(event) => setQuery(event.target.value)}
className="bg-background/60"
/>
</CardHeader>
<CardContent>
<ScrollArea className="h-[420px] pr-2">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredProfiles.map((profile) => {
const active = profile.id === selectedId;
return (
<TableRow
key={profile.id}
data-state={active ? "selected" : undefined}
className={cn(
"cursor-pointer",
active ? "bg-aethex-500/10" : "hover:bg-background/60",
)}
onClick={() => onSelectedIdChange(profile.id)}
>
<TableCell className="font-medium text-foreground/90">
<div className="flex flex-col">
<span>
{profile.full_name || profile.username || "Unknown"}
</span>
<span className="text-xs text-muted-foreground">
{profile.username}
</span>
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{profile.email || "—"}
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{(profile.role || "member").toLowerCase()}
</Badge>
</TableCell>
</TableRow>
);
})}
{!filteredProfiles.length ? (
<TableRow>
<TableCell
colSpan={3}
className="text-center text-muted-foreground"
>
No members found.
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</ScrollArea>
</CardContent>
</Card>
<div className="space-y-6">
<Card className="bg-card/60 border-border/40 backdrop-blur">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserCog className="h-5 w-5 text-teal-300" />
Member controls
</CardTitle>
<CardDescription>
Update profile data, loyalty, and Supabase attributes.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{selectedProfile && profileDraft ? (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="member-full-name">Full name</Label>
<Input
id="member-full-name"
value={profileDraft.full_name}
onChange={(event) =>
setProfileDraft((draft) =>
draft
? { ...draft, full_name: event.target.value }
: draft,
)
}
className="bg-background/60"
/>
</div>
<div className="space-y-2">
<Label htmlFor="member-location">Location</Label>
<Input
id="member-location"
value={profileDraft.location}
onChange={(event) =>
setProfileDraft((draft) =>
draft
? { ...draft, location: event.target.value }
: draft,
)
}
className="bg-background/60"
/>
</div>
<div className="space-y-2">
<Label>Experience level</Label>
<Select
value={profileDraft.experience_level}
onValueChange={(value) =>
setProfileDraft((draft) =>
draft ? { ...draft, experience_level: value } : draft,
)
}
>
<SelectTrigger className="bg-background/60">
<SelectValue placeholder="Select experience" />
</SelectTrigger>
<SelectContent>
{experienceOptions.map((option) => (
<SelectItem
key={option}
value={option}
className="capitalize"
>
{option.replace("_", " ")}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>User type</Label>
<Select
value={profileDraft.user_type}
onValueChange={(value) =>
setProfileDraft((draft) =>
draft ? { ...draft, user_type: value } : draft,
)
}
>
<SelectTrigger className="bg-background/60 capitalize">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
{userTypeOptions.map((option) => (
<SelectItem
key={option}
value={option}
className="capitalize"
>
{option.replace("_", " ")}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>Bio</Label>
<Textarea
value={profileDraft.bio}
onChange={(event) =>
setProfileDraft((draft) =>
draft ? { ...draft, bio: event.target.value } : draft,
)
}
className="bg-background/60"
rows={4}
/>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="member-level">Level</Label>
<Input
id="member-level"
value={profileDraft.level}
onChange={(event) =>
setProfileDraft((draft) =>
draft
? { ...draft, level: event.target.value }
: draft,
)
}
inputMode="numeric"
className="bg-background/60"
/>
</div>
<div className="space-y-2">
<Label htmlFor="member-total-xp">Total XP</Label>
<Input
id="member-total-xp"
value={profileDraft.total_xp}
onChange={(event) =>
setProfileDraft((draft) =>
draft
? { ...draft, total_xp: event.target.value }
: draft,
)
}
inputMode="numeric"
className="bg-background/60"
/>
</div>
<div className="space-y-2">
<Label htmlFor="member-loyalty">Loyalty points</Label>
<Input
id="member-loyalty"
value={profileDraft.loyalty_points}
onChange={(event) =>
setProfileDraft((draft) =>
draft
? { ...draft, loyalty_points: event.target.value }
: draft,
)
}
inputMode="numeric"
className="bg-background/60"
/>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
onClick={saveProfile}
disabled={savingProfile}
>
{savingProfile ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<BadgeCheck className="mr-2 h-4 w-4" />
)}
Save profile
</Button>
<Button size="sm" variant="outline" onClick={resetDraft}>
Reset changes
</Button>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">
Select a member to adjust their profile.
</p>
)}
</CardContent>
</Card>
<Card className="bg-card/60 border-border/40 backdrop-blur">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-emerald-300" />
Role management
</CardTitle>
<CardDescription>
Assign Supabase roles and governance permissions.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{selectedProfile ? (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
{roleOptions.map((role) => {
const active = roles.includes(role);
return (
<Button
key={role}
type="button"
variant={active ? "default" : "outline"}
size="sm"
onClick={() => handleRoleToggle(role)}
className={cn(
"capitalize",
active && "bg-aethex-500/80",
)}
>
{role}
</Button>
);
})}
</div>
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Custom role"
value={customRole}
onChange={(event) => setCustomRole(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
addCustomRole();
}
}}
className="bg-background/60 max-w-[200px]"
/>
<Button variant="outline" size="sm" onClick={addCustomRole}>
<Plus className="mr-2 h-4 w-4" /> Add role
</Button>
</div>
<div className="flex flex-wrap gap-2 text-sm">
{loadingRoles ? (
<span className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading roles
</span>
) : (
roles.map((role) => (
<Badge
key={role}
variant="outline"
className="capitalize"
>
{role}
</Badge>
))
)}
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
onClick={saveRoles}
disabled={savingRoles || loadingRoles}
>
{savingRoles ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<ShieldCheck className="mr-2 h-4 w-4" />
)}
Save roles
</Button>
<Button
size="sm"
variant="outline"
onClick={() =>
selectedProfile && loadRoles(selectedProfile.id)
}
disabled={loadingRoles}
>
<RefreshCw className="mr-2 h-4 w-4" /> Reload
</Button>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">
Select a member to manage their roles and permissions.
</p>
)}
</CardContent>
</Card>
</div>
</div>
);
};
export default AdminMemberManager;