From 84e3b238b16fb7fb60c3613f92a737e5bdceae4d Mon Sep 17 00:00:00 2001 From: incognitotgt Date: Sat, 20 Jul 2024 18:28:22 -0400 Subject: [PATCH] chore(general/v1.0): more account control and bug fixes * 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 --- .config/config-schema.json | 16 +++ next.config.mjs | 1 + package.json | 2 +- src/actions/image.ts | 3 - src/actions/user.ts | 37 +++--- src/app/(main)/admin/images/page.tsx | 2 +- src/app/(main)/admin/users/columns.tsx | 140 +++++++++++++++-------- src/app/auth/delete/page.tsx | 64 +++++++++++ src/app/auth/reset-password/action.ts | 4 +- src/components/create-session-button.tsx | 8 +- src/components/navbar.tsx | 8 +- src/lib/drizzle/schema.ts | 1 - src/lib/session/get-session.ts | 10 +- src/lib/session/index.ts | 27 +++-- src/types/config.d.ts | 9 ++ 15 files changed, 231 insertions(+), 101 deletions(-) create mode 100644 src/app/auth/delete/page.tsx diff --git a/.config/config-schema.json b/.config/config-schema.json index d117881..40da2df 100644 --- a/.config/config-schema.json +++ b/.config/config-schema.json @@ -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" } @@ -23,6 +24,7 @@ "providers": { "additionalProperties": { "additionalProperties": false, + "description": "the provider name", "properties": { "clientId": { "description": "The client ID for the OAuth provider.", @@ -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"], @@ -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" } } } diff --git a/next.config.mjs b/next.config.mjs index 47aaf34..bf7e3b2 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -23,6 +23,7 @@ const nextConfig = { instrumentationHook: true, webpackBuildWorker: true, reactCompiler: true, + after: true, serverActions: { allowedOrigins: ["localhost:3000", "*.use.devtunnels.ms"], }, diff --git a/package.json b/package.json index 4ffb43e..2888b03 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "stardust", - "version": "0.8-rc", + "version": "0.9", "private": true, "type": "module", "scripts": { diff --git a/src/actions/image.ts b/src/actions/image.ts index 439ad70..f2b3e1f 100644 --- a/src/actions/image.ts +++ b/src/actions/image.ts @@ -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 { @@ -40,7 +39,6 @@ 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, @@ -48,7 +46,6 @@ export async function addImage(data: FormData) { category: [validatedFields.data.category as string], friendlyName: validatedFields.data.friendlyName, icon: validatedFields.data.icon, - pulled: validatedFields.data.pulled, }, }); revalidatePath("/admin/images"); diff --git a/src/actions/user.ts b/src/actions/user.ts index 4ba30a1..9195eb1 100644 --- a/src/actions/user.ts +++ b/src/actions/user.ts @@ -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"); @@ -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 }; +} diff --git a/src/app/(main)/admin/images/page.tsx b/src/app/(main)/admin/images/page.tsx index 8844a71..2f9fdbb 100644 --- a/src/app/(main)/admin/images/page.tsx +++ b/src/app/(main)/admin/images/page.tsx @@ -44,7 +44,7 @@ export default async function AdminPage() { - + diff --git a/src/app/(main)/admin/users/columns.tsx b/src/app/(main)/admin/users/columns.tsx index 0655a5a..9500e13 100644 --- a/src/app/(main)/admin/users/columns.tsx +++ b/src/app/(main)/admin/users/columns.tsx @@ -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, @@ -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[] = [ @@ -47,64 +52,101 @@ export const columns: ColumnDef[] = [ }, { accessorKey: "id", - header: ({ column }) => , }, { accessorKey: "isAdmin", header: ({ column }) => , }, + { + accessorKey: "password", + header: ({ column }) => , + 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 ( - - - - - - Actions - navigator.clipboard.writeText(user.id)}>Copy user ID - - - 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 - - - toast.promise(() => deleteUserSessions(user.id), { - loading: "Deleting user's sessions...", - success: "Sessions deleted", - error: (error) => `Failed to delete sessions: ${error}`, - }) - } - > - Delete user's sessions - - - toast.promise(() => deleteUser(user.id), { - loading: "Deleting user...", - success: "User deleted", - error: (error) => `Failed to delete user: ${error}`, - }) - } - > - Delete user - - - + <> + + + + Reset password for {user.name || user.email} + +
+ 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" + > + + + Submit +
+
+
+ + + + + + Actions + navigator.clipboard.writeText(user.id)}>Copy user ID + + + 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 + + + toast.promise(() => deleteUserSessions(user.id), { + loading: "Deleting user's sessions...", + success: "Sessions deleted", + error: (error) => `Failed to delete sessions: ${error}`, + }) + } + > + Delete user's sessions + + setResetDialogOpen((prev) => !prev)}>Reset password + + toast.promise(() => deleteUser(user.id), { + loading: "Deleting user...", + success: "User deleted", + error: (error) => `Failed to delete user: ${error}`, + }) + } + > + Delete user + + + + ); }, }, diff --git a/src/app/auth/delete/page.tsx b/src/app/auth/delete/page.tsx new file mode 100644 index 0000000..181afcc --- /dev/null +++ b/src/app/auth/delete/page.tsx @@ -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 ( + + Delete your account + + + + + +
{ + "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(); + }} + > + + Are you sure you want to delete your account? + + Your account will be deleted and you will lose all sessions. After this action, you will be logged out. + + + + + + Continue + +
+
+
+
+ ); +} diff --git a/src/app/auth/reset-password/action.ts b/src/app/auth/reset-password/action.ts index 923525d..4d3aa74 100644 --- a/src/app/auth/reset-password/action.ts +++ b/src/app/auth/reset-password/action.ts @@ -7,7 +7,7 @@ import { eq } from "drizzle-orm"; import { redirect } from "next/navigation"; import { unstable_rethrow } from "next/navigation"; -export const resetPassword = async (dbUser: SelectUser | undefined, data: FormData) => { +export async function resetPassword(dbUser: SelectUser | undefined, data: FormData) { try { if (!dbUser) throw new Error("this ain't supposed to happen, why is there no user"); const newPw = data.get("new-password")?.toString(); @@ -35,4 +35,4 @@ export const resetPassword = async (dbUser: SelectUser | undefined, data: FormDa unstable_rethrow(error); throw error; } -}; +} diff --git a/src/components/create-session-button.tsx b/src/components/create-session-button.tsx index 01e5d07..afa4c48 100644 --- a/src/components/create-session-button.tsx +++ b/src/components/create-session-button.tsx @@ -12,12 +12,10 @@ export function CreateSessionButton({ image }: { image: string }) { disabled={isPending} onClick={() => startTransition(async () => { - const session = await createSession(image).catch((err) => { - toast.error(err.message); + const session = await createSession(image).catch(() => { + toast.error("Error creating session"); }); - if (!session) { - throw new Error("Container not created"); - } + if (!session) return; router.push(`/view/${session[0].id}`); }) } diff --git a/src/components/navbar.tsx b/src/components/navbar.tsx index eb500be..6c1de48 100644 --- a/src/components/navbar.tsx +++ b/src/components/navbar.tsx @@ -31,7 +31,7 @@ import { } from "@/components/ui/navigation-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import type { SelectUser } from "@/lib/drizzle/schema"; -import { Book, ComputerIcon, Globe, Info, Key, LogOut, Settings, Sparkles, SwatchBook } from "lucide-react"; +import { Book, ComputerIcon, Globe, Info, Key, LogOut, Settings, Sparkles, SwatchBook, Trash } from "lucide-react"; import type { Route } from "next"; import type { Session } from "next-auth"; import { useTheme } from "next-themes"; @@ -247,6 +247,12 @@ export default function Navigation({ Reset Password + + + + Delete Account + + diff --git a/src/lib/drizzle/schema.ts b/src/lib/drizzle/schema.ts index 883d2f3..b15fd91 100644 --- a/src/lib/drizzle/schema.ts +++ b/src/lib/drizzle/schema.ts @@ -18,7 +18,6 @@ export const image = pgTable("Image", { friendlyName: text("friendlyName").notNull(), category: text("category").array(), icon: text("icon").notNull(), - pulled: boolean("pulled").default(false).notNull(), }); export type SelectImage = typeof user.$inferSelect; export const insertImageSchema = createInsertSchema(image); diff --git a/src/lib/session/get-session.ts b/src/lib/session/get-session.ts index f1f436e..bac83fc 100644 --- a/src/lib/session/get-session.ts +++ b/src/lib/session/get-session.ts @@ -10,12 +10,10 @@ import { getConfig } from "../config"; * @returns The database query for the session along with the container IP, or `null` if it doesn't exist. */ async function getSession(containerId: string, userSession: Session | null) { - const [containerSession] = await db.transaction(async (tx) => { - return tx - .select() - .from(session) - .where(and(eq(session.id, containerId), eq(session.userId, userSession?.user.id as string))); - }); + const [containerSession] = await db + .select() + .from(session) + .where(and(eq(session.id, containerId), eq(session.userId, userSession?.user.id as string))); if (!containerSession) return null; const ip = (await docker.getContainer(containerId).inspect()).NetworkSettings.Networks[getConfig().docker.network] .IPAddress; diff --git a/src/lib/session/index.ts b/src/lib/session/index.ts index 31fb7be..9e84150 100644 --- a/src/lib/session/index.ts +++ b/src/lib/session/index.ts @@ -16,6 +16,7 @@ import { getSession } from "./get-session"; async function createSession(image: string) { consola.info(`✨ Stardust: Creating session with image ${image}`); try { + const config = getConfig(); const userSession = await auth(); if (!userSession?.user) throw new Error("User not found"); const id = `stardust-${crypto.randomUUID()}-${image.split("/")[2]}`; @@ -24,7 +25,7 @@ async function createSession(image: string) { Image: image, HostConfig: { ShmSize: 1024, - NetworkMode: getConfig().docker.network, + NetworkMode: config.docker.network, }, }); await container.start().catch((e) => { @@ -32,26 +33,24 @@ async function createSession(image: string) { throw new Error(`Container not started ${e.message}`); }); const date = new Date(); - date.setDate(date.getDate() + 7); + date.setHours(date.getHours() + (config.session?.keepaliveDuration || 1440)); return db - .transaction(async (tx) => { - return await tx - .insert(session) - .values({ - id: container.id, - dockerImage: image, - createdAt: Date.now(), - expiresAt: date.getTime(), - userId: userSession.user.id, - }) - .returning(); + .insert(session) + .values({ + id: container.id, + dockerImage: image, + createdAt: Date.now(), + expiresAt: date.getTime(), + userId: userSession.user.id, }) + .returning() .catch(async (e) => { await container.remove({ force: true }); - throw new Error(e.message); + throw e; }); } catch (error) { console.error(error); + throw error; } } /** diff --git a/src/types/config.d.ts b/src/types/config.d.ts index 8160bcf..4cbf74f 100644 --- a/src/types/config.d.ts +++ b/src/types/config.d.ts @@ -9,6 +9,7 @@ export interface Config { metadataUrl?: string; docker: DockerConfig; auth: AuthConfig; + session?: SessionConfig; } export interface DockerConfig { @@ -79,3 +80,11 @@ export interface AuthConfig { }; }; } + +export interface SessionConfig { + /** + * The amount of time to keep an inactive session alive for, in minutes. + * @default 1440 + */ + keepaliveDuration?: number; +}