Skip to content

Commit

Permalink
feat: admins can edit user, other bug fixes and imporvements in admin…
Browse files Browse the repository at this point in the history
… dash, homepage css changes
  • Loading branch information
IncognitoTGT committed Feb 16, 2025
1 parent 51aeee3 commit e3d9eb8
Show file tree
Hide file tree
Showing 8 changed files with 234 additions and 86 deletions.
24 changes: 23 additions & 1 deletion apps/web/src/app/(main)/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import auth from "@stardust/common/auth";
import { stardustConnector } from "@stardust/common/daemon/client";
import { getConfig } from "@stardust/config";
import db, { workspace, user } from "@stardust/db";
import { Container, Layers, Users } from "lucide-react";
import { Boxes, Container, Layers, Users } from "lucide-react";
import { headers } from "next/headers";
function mode<T>(arr: Array<T>) {
return arr.sort((a, b) => arr.filter((v) => v === a).length - arr.filter((v) => v === b).length).pop();
Expand All @@ -18,6 +20,16 @@ export default async function AdminPage() {
const workspaces = await tx.select().from(workspace);
return { users, sessions, workspaces };
});
const nodes = await Promise.all(
getConfig().nodes.map(async (n) => {
const node = stardustConnector(n);
return {
...n,
health: (await node.healthcheck.get()).data,
};
}),
);
const averageCpuUsage = nodes.reduce((acc, node) => acc + Number(node.health?.cpu), 0) / nodes.length;
const activeUsers = [...new Set(sessions.map((s) => s.userId))];
const admins = users.filter((u) => u.role === "admin");
return (
Expand Down Expand Up @@ -60,6 +72,16 @@ export default async function AdminPage() {
</p>
</CardContent>
</Card>
<Card className="w-64">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Nodes</CardTitle>
<Boxes className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{nodes.length}</div>
<p className="text-xs text-muted-foreground">Average CPU usage is {averageCpuUsage.toFixed(2)}%</p>
</CardContent>
</Card>
</section>
</div>
);
Expand Down
31 changes: 30 additions & 1 deletion apps/web/src/app/(main)/admin/users/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { check } from "@/lib/admin-check";
import { deleteSession } from "@/lib/session/manage";
import auth from "@stardust/common/auth";
import { hashPassword } from "@stardust/common/auth/lib";
import db, { account, session } from "@stardust/db";
import db, { account, session, user } from "@stardust/db";
import { and, eq } from "@stardust/db/utils";
import { revalidatePath } from "next/cache";
import { headers } from "next/headers";
Expand Down Expand Up @@ -74,3 +74,32 @@ export async function resetPassword(id: string, data: FormData) {
return { error: (e as Error).message };
}
}
export async function updateUser(id: string, data: FormData) {
await check();
try {
const where = eq(user.id, id);
const [dbEntry] = await db.select().from(user).where(where);
if (!dbEntry) throw new Error("no user found");
const name = data.get("name")?.toString();
const email = data.get("email")?.toString();
const image = data.get("image")?.toString();
const role = data.get("role")?.toString();
const res = await db
.update(user)
.set({
name,
email,
image,
role,
})
.where(where)
.returning();
revalidateHandler();
return {
error: undefined,
...res[0],
};
} catch (e) {
return { error: (e as Error).message };
}
}
137 changes: 59 additions & 78 deletions apps/web/src/app/(main)/admin/users/columns.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
"use client";
import { DataTableColumnHeader } from "@/components/data-table/column-header";
import { SubmitButton } from "@/components/submit-button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
Expand All @@ -13,16 +20,16 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import authClient from "@/lib/auth-client";
import type { ErrorContext } from "@stardust/common/auth/lib";
import type { SelectUserRelation } from "@stardust/db/relational-types";
import type { ColumnDef } from "@tanstack/react-table";
import { MoreHorizontal } from "lucide-react";
import Image from "next/image";
import { useState } from "react";
import { toast } from "sonner";
import { deleteUserSessions, resetPassword, revalidateHandler, safeDeleteUser } from "./actions";
import { deleteUserSessions, revalidateHandler, safeDeleteUser } from "./actions";
import { ResetPasswordDialog, UpdateUserDialog } from "./components";
const clientOptions = {
onSuccess() {
revalidateHandler();
Expand All @@ -36,6 +43,22 @@ export const columns: ColumnDef<SelectUserRelation>[] = [
accessorKey: "name",
header: ({ column }) => <DataTableColumnHeader column={column} title="Name" />,
},
{
accessorKey: "image",
header: "Profile Picture",
cell: ({ row }) =>
row.original.image ? (
<Image
className="size-12 border rounded-md"
alt={row.original.name}
src={row.original.image}
width={48}
height={48}
/>
) : (
<>None</>
),
},
{
accessorKey: "email",
header: ({ column }) => <DataTableColumnHeader column={column} title="Email" />,
Expand All @@ -55,47 +78,34 @@ export const columns: ColumnDef<SelectUserRelation>[] = [
id: "actions",
cell: ({ row: { original: user } }) => {
const [resetDialogOpen, setResetDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [updateDialogOpen, setUpdateDialogOpen] = useState(false);
return (
<>
<Dialog open={resetDialogOpen} onOpenChange={setResetDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reset password for {user.name || user.email}</DialogTitle>
</DialogHeader>
<form
action={(data) =>
toast.promise(
async () => {
const res = await resetPassword(user.id, data);
if (res?.error) throw new Error(res.error);
return res;
},
{
loading: "Resetting password...",
success: "Password reset",
error: (error) => `Failed to reset password: ${error.message}`,
},
)
}
className="flex flex-col gap-2 w-full"
>
<Label htmlFor="new-password">New password</Label>
<Input
id="new-password"
placeholder="New password"
name="new-password"
minLength={8}
type="password"
required
/>
<div className="flex items-center gap-2">
<Checkbox id="revoke-others" name="revoke-others" defaultChecked />
<Label htmlFor="revoke-others">Sign out of all other devices</Label>
</div>
<SubmitButton>Submit</SubmitButton>
</form>
</DialogContent>
</Dialog>
<ResetPasswordDialog user={user} open={resetDialogOpen} setOpen={setResetDialogOpen} />
<UpdateUserDialog user={user} open={updateDialogOpen} setOpen={setUpdateDialogOpen} />
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete User</AlertDialogTitle>
<AlertDialogDescription>Are you sure you want to delete {user.email}?</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Nevermind</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
toast.promise(() => safeDeleteUser(user.id), {
loading: "Deleting user...",
success: "User deleted",
error: (error) => `Failed to delete user: ${error.message}`,
})
}
>
Yes, delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
Expand All @@ -106,30 +116,10 @@ export const columns: ColumnDef<SelectUserRelation>[] = [
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={() => navigator.clipboard.writeText(user.id)}>Copy user ID</DropdownMenuItem>
<DropdownMenuItem onClick={() => setUpdateDialogOpen(true)}>Edit user</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={user.role === "admin"}
onCheckedChange={() =>
toast.promise(
() =>
authClient.admin.setRole(
{
userId: user.id,
role: user.role === "admin" ? "user" : "admin",
},
clientOptions,
),
{
loading: "Changing role...",
success: ({ data }) => `Role changed to ${data?.user.role}`,
error: (error) => `Failed to change role: ${error.message}`,
},
)
}
>
Admin
</DropdownMenuCheckboxItem>
<DropdownMenuItem onClick={() => setResetDialogOpen(true)}>Reset password</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() =>
toast.promise(() => authClient.admin.revokeUserSessions({ userId: user.id }, clientOptions), {
Expand All @@ -152,6 +142,7 @@ export const columns: ColumnDef<SelectUserRelation>[] = [
>
Delete user's sessions
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() =>
toast.promise(
Expand All @@ -170,17 +161,7 @@ export const columns: ColumnDef<SelectUserRelation>[] = [
>
{user.banned ? "Unban user" : "Ban user"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
toast.promise(() => safeDeleteUser(user.id), {
loading: "Deleting user...",
success: "User deleted",
error: (error) => `Failed to delete user: ${error.message}`,
})
}
>
Delete user
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteDialogOpen(true)}>Delete user</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
Expand Down
108 changes: 108 additions & 0 deletions apps/web/src/app/(main)/admin/users/components.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { SubmitButton } from "@/components/submit-button";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import authClient from "@/lib/auth-client";
import type { SelectUser } from "@stardust/db";
import { toast } from "sonner";
import { resetPassword, updateUser } from "./actions";

export interface Props {
user: SelectUser;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
open: boolean;
}
export function ResetPasswordDialog({ user, open, setOpen }: Props) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reset password for {user.name || user.email}</DialogTitle>
</DialogHeader>
<form
action={(data) =>
toast.promise(
async () => {
const res = await resetPassword(user.id, data);
if (res?.error) throw new Error(res.error);
return res;
},
{
loading: "Resetting password...",
success: "Password reset",
error: (error) => `Failed to reset password: ${error.message}`,
finally: () => setOpen(false),
},
)
}
className="flex flex-col gap-2 w-full"
>
<Label htmlFor="new-password">New password</Label>
<Input
id="new-password"
placeholder="New password"
name="new-password"
minLength={8}
type="password"
required
/>
<div className="flex items-center gap-2">
<Checkbox id="revoke-others" name="revoke-others" defaultChecked />
<Label htmlFor="revoke-others">Sign out of all other devices</Label>
</div>
<SubmitButton>Submit</SubmitButton>
</form>
</DialogContent>
</Dialog>
);
}
export function UpdateUserDialog({ user, open, setOpen }: Props) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Update {user.name}</DialogTitle>
</DialogHeader>
<form
action={(data) =>
toast.promise(
async () => {
const res = await updateUser(user.id, data);
if (res.error) {
throw new Error(res.error);
}
return res;
},
{
success: "User updated successfully",
error: (error) => `Failed to update user: ${error.message}`,
finally: () => setOpen(false),
},
)
}
className="flex flex-col gap-2 w-full"
>
<Label htmlFor="name">Name</Label>
<Input id="name" type="text" name="name" defaultValue={user.name} required />
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" name="email" defaultValue={user.email} required />
<Label htmlFor="image">Image</Label>
<Input id="image" type="url" name="image" defaultValue={user.image || undefined} />
<Label htmlFor="role">Role</Label>
<Select required name="role" defaultValue={user.role}>
<SelectTrigger id="role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
<SubmitButton>Save</SubmitButton>
</form>
</DialogContent>
</Dialog>
);
}
Loading

0 comments on commit e3d9eb8

Please sign in to comment.