Skip to content

Commit

Permalink
chore(general/v1.0): more account control and bug fixes
Browse files Browse the repository at this point in the history
* password reset by admin
* delete account page by end user
* remove nonneeded db column
* customizable session keepAlive
* minor bug fixes and improvements
* fix the package.json version
  • Loading branch information
IncognitoTGT committed Jul 20, 2024
1 parent 791d367 commit 84e3b23
Show file tree
Hide file tree
Showing 15 changed files with 231 additions and 101 deletions.
16 changes: 16 additions & 0 deletions .config/config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"description": "Credentials configuration. Leave `undefined` to disable user/password signups.",
"properties": {
"signups": {
"default": false,
"description": "Whether to allow user signups.",
"type": "boolean"
}
Expand All @@ -23,6 +24,7 @@
"providers": {
"additionalProperties": {
"additionalProperties": false,
"description": "the provider name",
"properties": {
"clientId": {
"description": "The client ID for the OAuth provider.",
Expand Down Expand Up @@ -71,6 +73,9 @@
"metadataUrl": {
"description": "The public URL of your Stardust instance. Use this if you want to display site metadata.",
"type": "string"
},
"session": {
"$ref": "#/definitions/SessionConfig"
}
},
"required": ["databaseUrl", "docker", "auth"],
Expand Down Expand Up @@ -105,6 +110,17 @@
},
"required": ["network"],
"type": "object"
},
"SessionConfig": {
"additionalProperties": false,
"properties": {
"keepaliveDuration": {
"default": 1440,
"description": "The amount of time to keep an inactive session alive for, in minutes.",
"type": "number"
}
},
"type": "object"
}
}
}
1 change: 1 addition & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const nextConfig = {
instrumentationHook: true,
webpackBuildWorker: true,
reactCompiler: true,
after: true,
serverActions: {
allowedOrigins: ["localhost:3000", "*.use.devtunnels.ms"],
},
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "stardust",
"version": "0.8-rc",
"version": "0.9",
"private": true,
"type": "module",
"scripts": {
Expand Down
3 changes: 0 additions & 3 deletions src/actions/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ export async function addImage(data: FormData) {
.split(",")
.map((cat) => cat.trim()),
icon: data.get("icon"),
pulled: true,
});
if (!validatedFields.success) {
return {
Expand All @@ -40,15 +39,13 @@ export async function addImage(data: FormData) {
dockerImage: validatedFields.data.dockerImage,
friendlyName: validatedFields.data.friendlyName,
icon: validatedFields.data.icon,
pulled: validatedFields.data.pulled,
})
.onConflictDoUpdate({
target: image.dockerImage,
set: {
category: [validatedFields.data.category as string],
friendlyName: validatedFields.data.friendlyName,
icon: validatedFields.data.icon,
pulled: validatedFields.data.pulled,
},
});
revalidatePath("/admin/images");
Expand Down
37 changes: 19 additions & 18 deletions src/actions/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,19 @@
import { auth } from "@/lib/auth";
import { db, session, user } from "@/lib/drizzle/db";
import { deleteSession } from "@/lib/session";
import { hash } from "argon2";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";

export async function deleteUser(userId: string) {
// only for admin things!!!!
async function throwErrorIfitsCurrentUser(userId: string) {
const userSession = await auth();
const { sessions, currentUser } = await db.transaction(async (tx) => {
const sessions = await tx.select().from(session).where(eq(session.userId, userId));
const currentUser = await tx.query.user.findFirst({
where: (user, { eq }) => eq(user.id, userSession?.user?.id as string),
});
return { sessions, currentUser };
});
if (currentUser?.isAdmin) {
throw new Error("Cannot delete the current user");
if (userSession?.user.id === userId) {
throw new Error("Cannot update the current user's admin status");
}
}
export async function deleteUser(userId: string, triggeredByUser = false) {
if (!triggeredByUser) throwErrorIfitsCurrentUser(userId);
const sessions = await db.select().from(session).where(eq(session.userId, userId));
await Promise.all(sessions.map((session) => deleteSession(session.id)));
await db.delete(user).where(eq(user.id, userId));
revalidatePath("/admin/users");
Expand All @@ -31,14 +29,17 @@ export async function deleteUserSessions(userId: string) {
return { success: true };
}
export async function changeUserAdminStatus(userId: string, isAdmin: boolean) {
const userSession = await auth();
const currentUser = await db.query.user.findFirst({
where: (user, { eq }) => eq(user.id, userSession?.user?.id as string),
});
if (currentUser?.isAdmin) {
throw new Error("Cannot change the admin status of the current user");
}
await throwErrorIfitsCurrentUser(userId);
const [update] = await db.update(user).set({ isAdmin }).where(eq(user.id, userId)).returning();
revalidatePath("/admin/users");
return { success: true, admin: update.isAdmin };
}

export async function resetUserPassword(userId: string, data: FormData) {
await throwErrorIfitsCurrentUser(userId);
await db
.update(user)
.set({ password: await hash(data.get("new-password")?.toString() as string) })
.where(eq(user.id, userId));
return { success: true };
}
2 changes: 1 addition & 1 deletion src/app/(main)/admin/images/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export default async function AdminPage() {
<Label htmlFor="name">Name</Label>
<Input id="name" placeholder="Name" name="friendlyName" minLength={2} required />
<Label htmlFor="cat">Category (comma seperated)</Label>
<Input id="cat" placeholder="Category" name="category" />
<Input id="cat" placeholder="Category" name="category" required />
<Label htmlFor="img">Docker pull URL</Label>
<Input id="img" placeholder="ghcr.io/spaceness/xxxx" name="dockerImage" required />
<Label htmlFor="icon">Icon</Label>
Expand Down
140 changes: 91 additions & 49 deletions src/app/(main)/admin/users/columns.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"use client";
import { changeUserAdminStatus, deleteUser, deleteUserSessions } from "@/actions/user";
import { changeUserAdminStatus, deleteUser, deleteUserSessions, resetUserPassword } from "@/actions/user";
import { DataTableColumnHeader } from "@/components/data-table/column-header";
import { StyledSubmit } from "@/components/submit-button";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
Expand All @@ -12,9 +14,12 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { SelectUserRelation } from "@/lib/drizzle/relational-types";
import type { ColumnDef } from "@tanstack/react-table";
import { MoreHorizontal } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";

export const columns: ColumnDef<SelectUserRelation>[] = [
Expand Down Expand Up @@ -47,64 +52,101 @@ export const columns: ColumnDef<SelectUserRelation>[] = [
},
{
accessorKey: "id",
header: ({ column }) => <DataTableColumnHeader column={column} title="ID" />,
},
{
accessorKey: "isAdmin",
header: ({ column }) => <DataTableColumnHeader column={column} title="Admin" />,
},
{
accessorKey: "password",
header: ({ column }) => <DataTableColumnHeader column={column} title="Password" />,
cell: ({ row }) => (row.original.password ? "Set" : "Not set"),
},
{
id: "actions",
cell: ({ row }) => {
// why does this feel illegal
const [resetDialogOpen, setResetDialogOpen] = useState(false);
const user = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={() => navigator.clipboard.writeText(user.id)}>Copy user ID</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={user.isAdmin}
onCheckedChange={() =>
toast.promise(() => changeUserAdminStatus(user.id, !user.isAdmin), {
loading: "Changing user's admin status...",
success: ({ admin }) => `Admin status changed to ${admin}`,
error: (error) => `Failed to change admin status: ${error}`,
})
}
>
Admin
</DropdownMenuCheckboxItem>
<DropdownMenuItem
onClick={() =>
toast.promise(() => deleteUserSessions(user.id), {
loading: "Deleting user's sessions...",
success: "Sessions deleted",
error: (error) => `Failed to delete sessions: ${error}`,
})
}
>
Delete user's sessions
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
toast.promise(() => deleteUser(user.id), {
loading: "Deleting user...",
success: "User deleted",
error: (error) => `Failed to delete user: ${error}`,
})
}
>
Delete user
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<>
<Dialog open={resetDialogOpen} onOpenChange={setResetDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reset password for {user.name || user.email}</DialogTitle>
</DialogHeader>
<form
action={(data) =>
toast.promise(() => resetUserPassword(user.id, data), {
loading: "Resetting password...",
success: "Password reset",
error: "Failed to reset password",
})
}
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
/>
<StyledSubmit>Submit</StyledSubmit>
</form>
</DialogContent>
</Dialog>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={() => navigator.clipboard.writeText(user.id)}>Copy user ID</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={user.isAdmin}
onCheckedChange={() =>
toast.promise(() => changeUserAdminStatus(user.id, !user.isAdmin), {
loading: "Changing user's admin status...",
success: ({ admin }) => `Admin status changed to ${admin}`,
error: (error) => `Failed to change admin status: ${error}`,
})
}
>
Admin
</DropdownMenuCheckboxItem>
<DropdownMenuItem
onClick={() =>
toast.promise(() => deleteUserSessions(user.id), {
loading: "Deleting user's sessions...",
success: "Sessions deleted",
error: (error) => `Failed to delete sessions: ${error}`,
})
}
>
Delete user's sessions
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setResetDialogOpen((prev) => !prev)}>Reset password</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
toast.promise(() => deleteUser(user.id), {
loading: "Deleting user...",
success: "User deleted",
error: (error) => `Failed to delete user: ${error}`,
})
}
>
Delete user
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
);
},
},
Expand Down
64 changes: 64 additions & 0 deletions src/app/auth/delete/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { deleteUser } from "@/actions/user";
import { StyledSubmit } from "@/components/submit-button";
import { Button } from "@/components/ui/button";
import { CardContent, CardDescription } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { auth, signOut } from "@/lib/auth";
import { unstable_after } from "next/server";

export default async function Page() {
const userSession = await auth();
const delString = `I wish to delete ${userSession?.user.email}`;
return (
<CardContent className="m-1 w-full flex-col flex justify-center items-center gap-4">
<CardDescription>Delete your account</CardDescription>
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive" className="w-full">
Get started
</Button>
</DialogTrigger>
<DialogContent>
<form
className="flex flex-col gap-4"
action={async (data) => {
"use server";
unstable_after(async () => deleteUser(userSession?.user.id as string, true));
const delSure = data.get("delete-sure")?.toString();
if (delSure !== delString) return;
await signOut();
}}
>
<DialogHeader>
<DialogTitle>Are you sure you want to delete your account?</DialogTitle>
<DialogDescription>
Your account will be deleted and you will lose all sessions. After this action, you will be logged out.
</DialogDescription>
</DialogHeader>
<Label htmlFor="delete-sure">Enter "{delString}"</Label>
<Input
id="delete-sure"
name="delete-sure"
required
minLength={delString.length}
maxLength={delString.length}
/>
<DialogFooter>
<StyledSubmit>Continue</StyledSubmit>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</CardContent>
);
}
Loading

0 comments on commit 84e3b23

Please sign in to comment.