[MAGE] add delete user option (#1577)

* add delete user functionality

* refactor to deleteMyself

* delete user relevant info from projects

* update status to deleted
This commit is contained in:
vincanger 2023-11-23 10:42:03 +01:00 committed by GitHub
parent 080a1fdbf9
commit 3825ef94ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 272 additions and 56 deletions

6
wasp-ai/.prettierrc Normal file
View File

@ -0,0 +1,6 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "es5",
"printWidth": 100
}

View File

@ -107,6 +107,11 @@ action createFeedback {
entities: [Feedback]
}
action deleteMyself {
fn: import { deleteMyself } from "@server/operations.js",
entities: [User, Project, File, Log]
}
query getFeedback {
fn: import { getFeedback } from "@server/operations.js",
entities: [Feedback]
@ -155,7 +160,7 @@ entity SocialLogin {=psl
providerId String
userId Int
user User @relation(fields: [userId], references: [id])
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
psl=}

View File

@ -0,0 +1,5 @@
-- DropForeignKey
ALTER TABLE "SocialLogin" DROP CONSTRAINT "SocialLogin_userId_fkey";
-- AddForeignKey
ALTER TABLE "SocialLogin" ADD CONSTRAINT "SocialLogin_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -49,9 +49,9 @@ export function MyDialog({ isOpen, onClose, title, children }) {
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
clipRule="evenodd"
></path>
</svg>
<span className="sr-only">Close modal</span>

View File

@ -7,6 +7,7 @@ export function StatusPill({ children, status, className = "", sm = false }) {
error: "bg-red-100 text-red-700",
cancelled: "bg-red-100 text-red-700",
warning: "bg-yellow-100 text-yellow-700",
deleted: "bg-red-100 text-red-700",
};
return (
<div className={`flex items-center ${className}`}>

View File

@ -77,7 +77,7 @@ export function WaitingRoomContent(props) {
<div className="grid grid-cols-1 gap-2 lg:grid-cols-2 lg:gap-4">
{showcaseSamples.map((sample) => (
<ShowcaseCard {...sample} />
<ShowcaseCard key={sample.name} {...sample} />
))}
</div>
</>

View File

@ -42,7 +42,11 @@ export const ResultPage = () => {
data: appGenerationResult,
isError,
isLoading,
} = useQuery(getAppGenerationResult, { appId }, { enabled: !!appId && !generationDone, refetchInterval: 3000 });
} = useQuery(
getAppGenerationResult,
{ appId },
{ enabled: !!appId && !generationDone, refetchInterval: 3000 }
);
const [activeFilePath, setActiveFilePath] = useState(null);
const [currentStatus, setCurrentStatus] = useState({
status: "idle",
@ -211,7 +215,10 @@ export const ResultPage = () => {
return (
<div className="container">
<Header currentStatus={currentStatus} StatusPill={!!appGenerationResult?.project && StatusPill}>
<Header
currentStatus={currentStatus}
StatusPill={!!appGenerationResult?.project && StatusPill}
>
<FaqButton />
<HomeButton />
<ProfileButton />
@ -225,7 +232,8 @@ export const ResultPage = () => {
{isError && (
<div className="mb-4 bg-red-50 p-8 rounded-xl">
<div className="text-red-500">
We couldn't find the app generation result. Maybe the link is incorrect or the app generation has failed.
We couldn't find the app generation result. Maybe the link is incorrect or the app
generation has failed.
</div>
<Link className="button gray sm mt-4 inline-block" to="/">
Generate a new one
@ -244,7 +252,16 @@ export const ResultPage = () => {
</>
)}
<Logs logs={logs} status={currentStatus.status} onRetry={retry} />
{appGenerationResult?.project.status.includes("deleted") ? (
<div className="flex flex-col items-center justify-center gap-1 mb-4 bg-red-50 text-gray-700 p-8 rounded-xl">
<span>This app has been deleted. </span>
<Link className="underline sm inline-block" to="/">
&#x2190; Go back and generate a new app
</Link>
</div>
) : (
<Logs logs={logs} status={currentStatus.status} onRetry={retry} />
)}
<div
className="overflow-hidden
@ -264,31 +281,45 @@ export const ResultPage = () => {
onClick={() => window.open("https://github.com/wasp-lang/wasp/tree/wasp-ai")}
>
<span>
🔮 This is a Wasp powered project. If you like it, <span className="underline">star us on GitHub</span>!
🔮 This is a Wasp powered project. If you like it,{" "}
<span className="underline">star us on GitHub</span>!
</span>
</span>
</div>
</div>
{currentStatus.status === "pending" && (
<WaitingRoomContent numberOfProjectsAheadInQueue={appGenerationResult?.numberOfProjectsAheadInQueue || 0} />
<WaitingRoomContent
numberOfProjectsAheadInQueue={appGenerationResult?.numberOfProjectsAheadInQueue || 0}
/>
)}
{interestingFilePaths.length > 0 && (
<>
<div className="mb-2 flex items-center justify-between">
<h2 className="text-xl font-bold text-gray-800">{appGenerationResult?.project?.name}</h2>
<h2 className="text-xl font-bold text-gray-800">
{appGenerationResult?.project?.name}
</h2>
</div>
<button className="button gray block w-full mb-4 md:hidden" onClick={toggleMobileFileBrowser}>
{isMobileFileBrowserOpen ? "Close" : "Open"} file browser ({interestingFilePaths.length} files)
<button
className="button gray block w-full mb-4 md:hidden"
onClick={toggleMobileFileBrowser}
>
{isMobileFileBrowserOpen ? "Close" : "Open"} file browser ({interestingFilePaths.length}{" "}
files)
</button>
<div className="grid gap-4 md:grid-cols-[320px_1fr] mt-4 overflow-x-auto md:overflow-x-visible">
<aside className={isMobileFileBrowserOpen ? "" : "hidden md:block"}>
<div className="mb-2">
<RunTheAppModal onDownloadZip={downloadZip} disabled={currentStatus.status !== "success"} />
<RunTheAppModal
onDownloadZip={downloadZip}
disabled={currentStatus.status !== "success"}
/>
</div>
{currentStatus.status !== "success" && (
<small className="text-gray-500 text-center block my-2">The app is still being generated.</small>
<small className="text-gray-500 text-center block my-2">
The app is still being generated.
</small>
)}
<div>
<ShareButton />
@ -353,6 +384,7 @@ function getStatusPillData(generationResult) {
success: "success",
failure: "error",
cancelled: "cancelled",
deleted: "deleted"
};
const queueCardinalNumber = getCardinalNumber(generationResult.numberOfProjectsAheadInQueue);
@ -363,6 +395,7 @@ function getStatusPillData(generationResult) {
success: "Finished",
failure: "There was an error",
cancelled: "The generation was cancelled",
deleted: "The project was deleted"
};
return {
@ -392,12 +425,16 @@ export function OnSuccessModal({ isOpen, setIsOpen, appGenerationResult }) {
const logText = appGenerationResult?.project?.logs?.find((log) =>
log.content.includes("tokens usage")
)?.content;
const regex = /total tokens usage: ~(\d+(\.\d+)?)/i;
const match = logText?.match(regex);
if (match && match[1]) {
const num = Number(match[1]) * 1000;
if (Number.isInteger(num)) {
setNumTokensSpent(num);
if (logText) {
const regex = /Total\s+tokens\s+usage\s*:\s*~\s*(\d+(?:\.\d+){0,1})\s*k\b/;
const match = logText.match(regex);
if (match) {
const num = parseFloat(match[1]);
setNumTokensSpent(num * 1000);
} else {
console.log("Failed to parse total number of tokens used: no regex match.");
}
}
}, [appGenerationResult]);
@ -416,10 +453,15 @@ export function OnSuccessModal({ isOpen, setIsOpen, appGenerationResult }) {
}
return (
<MyDialog isOpen={isOpen} onClose={() => setIsOpen(false)} title={<span>Your App is Ready! 🎉</span>}>
<MyDialog
isOpen={isOpen}
onClose={() => setIsOpen(false)}
title={<span>Your App is Ready! 🎉</span>}
>
<div className="mt-6 space-y-5">
<p className="text-base leading-relaxed text-gray-500">
We've made this tool completely <span className="font-semibold">free</span> and cover all the costs 😇
We've made this tool completely <span className="font-semibold">free</span> and cover all
the costs 😇
</p>
{numTokensSpent > 0 && (
<table className="bg-slate-50 rounded-lg divide-y divide-gray-100 w-full text-base leading-relaxed text-gray-500 text-sm">
@ -435,7 +477,9 @@ export function OnSuccessModal({ isOpen, setIsOpen, appGenerationResult }) {
<td className="p-2 text-gray-600"> Cost to generate your app: </td>
<td className="p-2 text-gray-600">
{" "}
<FormattedText>{`$${((Number(numTokensSpent) / 1000) * 0.004).toFixed(2)}`}</FormattedText>{" "}
<FormattedText>{`$${((Number(numTokensSpent) / 1000) * 0.004).toFixed(
2
)}`}</FormattedText>{" "}
</td>
</tr>
{numTotalProjects && (
@ -477,7 +521,9 @@ export default function RunTheAppModal({ disabled, onDownloadZip }) {
return (
<>
<button
className={`button flex items-center justify-center gap-1 w-full${!disabled ? " animate-jumping" : ""}`}
className={`button flex items-center justify-center gap-1 w-full${
!disabled ? " animate-jumping" : ""
}`}
disabled={disabled}
onClick={() => setShowModal(true)}
>
@ -497,11 +543,16 @@ export default function RunTheAppModal({ disabled, onDownloadZip }) {
Congrats, your full-stack web app is ready! 🎉
<br />
App is implemented in{" "}
<a href="https://wasp-lang.dev" target="_blank" rel="noopener noreferrer" className="underline">
<a
href="https://wasp-lang.dev"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Wasp
</a>{" "}
web framework, using React, Node.js and Prisma, and is completely full-stack (frontend + backend +
database).
web framework, using React, Node.js and Prisma, and is completely full-stack (frontend +
backend + database).
</p>
<WarningAboutAI />
@ -511,7 +562,11 @@ export default function RunTheAppModal({ disabled, onDownloadZip }) {
<div className="mt-6 bg-slate-100 rounded-lg p-4 text-base text-slate-800">
<h2 className="font-bold flex items-center space-x-1">
<span>1. Install Wasp CLI</span>
<a href="https://wasp-lang.dev/docs/quick-start#installation-1" target="blank" rel="noopener noreferrer">
<a
href="https://wasp-lang.dev/docs/quick-start#installation-1"
target="blank"
rel="noopener noreferrer"
>
{" "}
<RxQuestionMarkCircled className="text-base" />{" "}
</a>
@ -521,8 +576,14 @@ export default function RunTheAppModal({ disabled, onDownloadZip }) {
curl -sSL https://get.wasp-lang.dev/installer.sh | sh
</pre>
<h2 className="font-bold mt-4"> 2. Download the generated app files and unzip them: </h2>
<button className="button flex items-center justify-center gap-1 w-full mt-2" onClick={onDownloadZip}>
<h2 className="font-bold mt-4">
{" "}
2. Download the generated app files and unzip them:{" "}
</h2>
<button
className="button flex items-center justify-center gap-1 w-full mt-2"
onClick={onDownloadZip}
>
Download ZIP <PiDownloadDuotone className="inline-block" size={20} />
</button>
@ -536,11 +597,17 @@ export default function RunTheAppModal({ disabled, onDownloadZip }) {
</pre>
</div>
<p className="text-base leading-relaxed text-gray-500">Congratulations, you are now running your app! 🎉</p>
<p className="text-base leading-relaxed text-gray-500">
Congratulations, you are now running your app! 🎉
</p>
<div className="bg-pink-50 text-pink-800 p-4 rounded">
If you like this project,{" "}
<a href="https://github.com/wasp-lang/wasp" target="_blank" className="underline text-pink-600">
<a
href="https://github.com/wasp-lang/wasp"
target="_blank"
className="underline text-pink-600"
>
star us on GitHub
</a>{" "}
@ -559,8 +626,8 @@ function WarningAboutAI() {
<p className="text-sm leading-5 font-medium"> Experimental tech</p>
<div className="mt-2 text-sm leading-5">
<p>
Since this is a GPT generated app, it might contain some mistakes, proportional to how complex the app is.
If there are some in your app, check out{" "}
Since this is a GPT generated app, it might contain some mistakes, proportional to how
complex the app is. If there are some in your app, check out{" "}
<a
href="https://wasp-lang.dev/docs"
target="_blank"
@ -578,8 +645,8 @@ function WarningAboutAI() {
>
Discord
</a>
! You can also try generating the app again to get different results (try playing with the creativity
level).
! You can also try generating the app again to get different results (try playing with
the creativity level).
</p>
</div>
</div>
@ -632,17 +699,22 @@ function Feedback({ projectId }) {
>
<form onSubmit={handleSubmit}>
<label className="text-slate-700 block mb-2 mt-8">
How likely are you to recommend this tool to a friend? <span className="text-red-500">*</span>
How likely are you to recommend this tool to a friend?{" "}
<span className="text-red-500">*</span>
</label>
<div className="mx-auto w-full max-w-md">
<RadioGroup value={score} onChange={setScore}>
<div className="flex space-x-2">
{scoreOptions.map((option) => (
<RadioGroup.Option value={option}>
<RadioGroup.Option key={option} value={option}>
{({ active, checked }) => (
<div
className={`
${active ? "ring-2 ring-white ring-opacity-60 ring-offset-2 ring-offset-sky-300" : ""}
${
active
? "ring-2 ring-white ring-opacity-60 ring-offset-2 ring-offset-sky-300"
: ""
}
${checked ? "bg-sky-900 bg-opacity-75 text-white" : ""}

View File

@ -1,20 +1,27 @@
import { useState } from "react";
import { useQuery } from "@wasp/queries";
import getProjectsByUser from "@wasp/queries/getProjectsByUser";
import { Link } from "@wasp/router";
import { Color } from "../components/Color";
import { Header } from "../components/Header";
import { PiDownloadDuotone } from "react-icons/pi";
import { PiDownloadDuotone, PiSealWarningDuotone } from "react-icons/pi";
import logout from "@wasp/auth/logout";
import { FiLogOut } from "react-icons/fi";
import { format } from "timeago.js";
import { StatusPill } from "../components/StatusPill";
import { getTailwindClassNameForProjectBrandColor, getTailwindClassNameForProjectStatus, projectStatusToDisplayableText } from "../project/utils";
import {
getTailwindClassNameForProjectBrandColor,
getTailwindClassNameForProjectStatus,
projectStatusToDisplayableText,
} from "../project/utils";
import { HomeButton } from "../components/Header";
import deleteMyself from "@wasp/actions/deleteMyself";
import { MyDialog } from "../components/Dialog";
export function UserPage({ user }) {
const [isDeleteUserModalOpen, setIsDeleteUserModalOpen] = useState(false);
const { data: projects, isLoading, error } = useQuery(getProjectsByUser);
if (isLoading) return "Loading Projects...";
if (error) return "Error loading projects.";
return (
@ -22,6 +29,13 @@ export function UserPage({ user }) {
<Header>
<HomeButton />
</Header>
<DeleteUserModal
isOpen={isDeleteUserModalOpen}
setIsOpen={setIsDeleteUserModalOpen}
deleteUser={deleteMyself}
/>
<div className="big-box">
<div className="flex items-center justify-between pb-6 pl-1">
<p className="text-gray-700 mr-2 whitespace-nowrap">
@ -34,9 +48,18 @@ export function UserPage({ user }) {
</div>
</button>
</div>
<div className="sm:rounded-lg shadow-md overflow-x-auto ">
<UserProjectsTable projects={projects} />
</div>
{isLoading ? (
"Loading..."
) : (
<div className="sm:rounded-lg shadow-md overflow-x-auto ">
<UserProjectsTable projects={projects} />
</div>
)}
</div>
<div className="flex justify-end pt-8 px-10">
<button onClick={() => setIsDeleteUserModalOpen(true)} className="text-xs text-gray-500 hover:underline">
*I want to delete my account.
</button>
</div>
</div>
);
@ -63,7 +86,7 @@ function UserProjectsTable({ projects }) {
</tr>
</thead>
<tbody>
{projects?.length > 0 &&
{!!projects && projects?.length > 0 ? (
projects.map((project) => (
<tr className="bg-white border-t" key={project.id}>
<th scope="row" className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap flex items-center gap-2">
@ -100,8 +123,54 @@ function UserProjectsTable({ projects }) {
</Link>
</td>
</tr>
))}
))
) : (
<tr className="bg-white border-t">
<td colSpan={5} className="text-center py-4">
you have not generated any apps yet.
</td>
</tr>
)}
</tbody>
</table>
);
}
}
function DeleteUserModal({ isOpen, setIsOpen, deleteUser }) {
async function deleteUserHandler() {
await deleteUser();
logout();
}
return (
<MyDialog
isOpen={isOpen}
onClose={() => setIsOpen(false)}
title={
<div className="flex items-center gap-2">
<PiSealWarningDuotone arie-hidden="true" /> Are You Sure You Want to Delete Your Account?
</div>
}
>
<div className="mt-10 space-y-10">
<p className="px-8 text-base leading-relaxed text-center text-gray-500">
You will lose access to your current projects and data.
</p>
<div className="flex items-center justify-between">
<button
className="px-4 py-2 text-base font-medium text-white bg-red-600 border border-transparent rounded-md shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
onClick={deleteUserHandler}
>
Delete Account
</button>
<button
className="px-4 py-2 text-base font-medium text-gray-700 bg-gray-100 border border-transparent rounded-md shadow-sm hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
onClick={() => setIsOpen(false)}
>
Cancel
</button>
</div>
</div>
</MyDialog>
);
}

View File

@ -1,9 +1,20 @@
import { RegisterZipDownload, StartGeneratingNewApp, CreateFeedback } from "@wasp/actions/types";
import { GetAppGenerationResult, GetStats, GetFeedback, GetNumProjects, GetProjectsByUser } from "@wasp/queries/types";
import {
RegisterZipDownload,
StartGeneratingNewApp,
CreateFeedback,
DeleteMyself,
} from "@wasp/actions/types";
import {
GetAppGenerationResult,
GetStats,
GetFeedback,
GetNumProjects,
GetProjectsByUser,
} from "@wasp/queries/types";
import HttpError from "@wasp/core/HttpError.js";
import { checkPendingAppsJob } from "@wasp/jobs/checkPendingAppsJob.js";
import { getNowInUTC } from "./utils.js";
import type { Project } from "@wasp/entities";
import type { Project, User } from "@wasp/entities";
export const startGeneratingNewApp: StartGeneratingNewApp<
{
@ -206,10 +217,10 @@ export const getStats = (async (_args, context) => {
zipDownloadedAt: true,
user: {
select: {
email: true
}
}
}
email: true,
},
},
},
});
return {
@ -237,3 +248,50 @@ export const getProjectsByUser: GetProjectsByUser<void, Project[]> = async (_arg
},
});
};
export const deleteMyself: DeleteMyself<void, User> = async (args, context) => {
if (!context.user) {
throw new HttpError(401, "Not authorized");
}
try {
await context.entities.Log.deleteMany({
where: {
project: {
user: {
id: context.user.id,
},
},
},
});
await context.entities.File.deleteMany({
where: {
project: {
user: {
id: context.user.id,
},
},
},
});
await context.entities.Project.updateMany({
where: {
user: {
id: context.user.id,
},
},
data: {
zipDownloadedAt: undefined,
name: "Deleted project",
description: "Deleted project",
status: "deleted",
},
});
return await context.entities.User.delete({
where: {
id: context.user.id,
},
});
} catch (error) {
console.error(error);
throw new HttpError(500, "Error deleting user");
}
};