diff --git a/messages/en-US.json b/messages/en-US.json index e68d257c..3e825711 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -201,6 +201,7 @@ "protocolSelect": "Select a protocol", "resourcePortNumber": "Port Number", "resourcePortNumberDescription": "The external port number to proxy requests.", + "back": "Back", "cancel": "Cancel", "resourceConfig": "Configuration Snippets", "resourceConfigDescription": "Copy and paste these configuration snippets to set up the TCP/UDP resource", @@ -246,6 +247,17 @@ "orgErrorDeleteMessage": "An error occurred while deleting the organization.", "orgDeleted": "Organization deleted", "orgDeletedMessage": "The organization and its data has been deleted.", + "deleteAccount": "Delete Account", + "deleteAccountDescription": "Permanently delete your account, all organizations you own, and all data within those organizations. This cannot be undone.", + "deleteAccountButton": "Delete Account", + "deleteAccountConfirmTitle": "Delete Account", + "deleteAccountConfirmMessage": "This will permanently wipe your account, all organizations you own, and all data within those organizations. This cannot be undone.", + "deleteAccountConfirmString": "delete account", + "deleteAccountSuccess": "Account Deleted", + "deleteAccountSuccessMessage": "Your account has been deleted.", + "deleteAccountError": "Failed to delete account", + "deleteAccountPreviewAccount": "Your Account", + "deleteAccountPreviewOrgs": "Organizations you own (and all their data)", "orgMissing": "Organization ID Missing", "orgMissingMessage": "Unable to regenerate invitation without an organization ID.", "accessUsersManage": "Manage Users", diff --git a/server/lib/deleteOrg.ts b/server/lib/deleteOrg.ts new file mode 100644 index 00000000..7295555d --- /dev/null +++ b/server/lib/deleteOrg.ts @@ -0,0 +1,169 @@ +import { + clients, + clientSiteResourcesAssociationsCache, + clientSitesAssociationsCache, + db, + domains, + olms, + orgDomains, + orgs, + resources, + sites +} from "@server/db"; +import { newts, newtSessions } from "@server/db"; +import { eq, and, inArray, sql } from "drizzle-orm"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { sendToClient } from "#dynamic/routers/ws"; +import { deletePeer } from "@server/routers/gerbil/peers"; +import { OlmErrorCodes } from "@server/routers/olm/error"; +import { sendTerminateClient } from "@server/routers/client/terminate"; + +export type DeleteOrgByIdResult = { + deletedNewtIds: string[]; + olmsToTerminate: string[]; +}; + +/** + * Deletes one organization and its related data. Returns ids for termination + * messages; caller should call sendTerminationMessages with the result. + * Throws if org not found. + */ +export async function deleteOrgById( + orgId: string +): Promise { + const [org] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (!org) { + throw createHttpError( + HttpCode.NOT_FOUND, + `Organization with ID ${orgId} not found` + ); + } + + const orgSites = await db + .select() + .from(sites) + .where(eq(sites.orgId, orgId)) + .limit(1); + + const orgClients = await db + .select() + .from(clients) + .where(eq(clients.orgId, orgId)); + + const deletedNewtIds: string[] = []; + const olmsToTerminate: string[] = []; + + await db.transaction(async (trx) => { + for (const site of orgSites) { + if (site.pubKey) { + if (site.type == "wireguard") { + await deletePeer(site.exitNodeId!, site.pubKey); + } else if (site.type == "newt") { + const [deletedNewt] = await trx + .delete(newts) + .where(eq(newts.siteId, site.siteId)) + .returning(); + if (deletedNewt) { + deletedNewtIds.push(deletedNewt.newtId); + await trx + .delete(newtSessions) + .where( + eq(newtSessions.newtId, deletedNewt.newtId) + ); + } + } + } + logger.info(`Deleting site ${site.siteId}`); + await trx.delete(sites).where(eq(sites.siteId, site.siteId)); + } + for (const client of orgClients) { + const [olm] = await trx + .select() + .from(olms) + .where(eq(olms.clientId, client.clientId)) + .limit(1); + if (olm) { + olmsToTerminate.push(olm.olmId); + } + logger.info(`Deleting client ${client.clientId}`); + await trx + .delete(clients) + .where(eq(clients.clientId, client.clientId)); + await trx + .delete(clientSiteResourcesAssociationsCache) + .where( + eq( + clientSiteResourcesAssociationsCache.clientId, + client.clientId + ) + ); + await trx + .delete(clientSitesAssociationsCache) + .where( + eq(clientSitesAssociationsCache.clientId, client.clientId) + ); + } + const allOrgDomains = await trx + .select() + .from(orgDomains) + .innerJoin(domains, eq(domains.domainId, orgDomains.domainId)) + .where( + and( + eq(orgDomains.orgId, orgId), + eq(domains.configManaged, false) + ) + ); + const domainIdsToDelete: string[] = []; + for (const orgDomain of allOrgDomains) { + const domainId = orgDomain.domains.domainId; + const orgCount = await trx + .select({ count: sql`count(*)` }) + .from(orgDomains) + .where(eq(orgDomains.domainId, domainId)); + if (orgCount[0].count === 1) { + domainIdsToDelete.push(domainId); + } + } + if (domainIdsToDelete.length > 0) { + await trx + .delete(domains) + .where(inArray(domains.domainId, domainIdsToDelete)); + } + await trx.delete(resources).where(eq(resources.orgId, orgId)); + await trx.delete(orgs).where(eq(orgs.orgId, orgId)); + }); + + return { deletedNewtIds, olmsToTerminate }; +} + +export function sendTerminationMessages(result: DeleteOrgByIdResult): void { + for (const newtId of result.deletedNewtIds) { + sendToClient(newtId, { type: `newt/wg/terminate`, data: {} }).catch( + (error) => { + logger.error( + "Failed to send termination message to newt:", + error + ); + } + ); + } + for (const olmId of result.olmsToTerminate) { + sendTerminateClient( + 0, + OlmErrorCodes.TERMINATED_REKEYED, + olmId + ).catch((error) => { + logger.error( + "Failed to send termination message to olm:", + error + ); + }); + } +} diff --git a/server/routers/auth/deleteMyAccount.ts b/server/routers/auth/deleteMyAccount.ts new file mode 100644 index 00000000..2c37cd09 --- /dev/null +++ b/server/routers/auth/deleteMyAccount.ts @@ -0,0 +1,228 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, orgs, userOrgs, users } from "@server/db"; +import { eq, and, inArray } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { verifySession } from "@server/auth/sessions/verifySession"; +import { + invalidateSession, + createBlankSessionTokenCookie +} from "@server/auth/sessions/app"; +import { verifyPassword } from "@server/auth/password"; +import { verifyTotpCode } from "@server/auth/totp"; +import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; +import { + deleteOrgById, + sendTerminationMessages +} from "@server/lib/deleteOrg"; +import { UserType } from "@server/types/UserTypes"; + +const deleteMyAccountBody = z.strictObject({ + password: z.string().optional(), + code: z.string().optional() +}); + +export type DeleteMyAccountPreviewResponse = { + preview: true; + orgs: { orgId: string; name: string }[]; + twoFactorEnabled: boolean; +}; + +export type DeleteMyAccountCodeRequestedResponse = { + codeRequested: true; +}; + +export type DeleteMyAccountSuccessResponse = { + success: true; +}; + +/** + * Self-service account deletion (saas only). Returns preview when no password; + * requires password and optional 2FA code to perform deletion. Uses shared + * deleteOrgById for each owned org (delete-my-account may delete multiple orgs). + */ +export async function deleteMyAccount( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const { user, session } = await verifySession(req); + if (!user || !session) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Not authenticated") + ); + } + + if (user.serverAdmin) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Server admins cannot delete their account this way" + ) + ); + } + + if (user.type !== UserType.Internal) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Account deletion with password is only supported for internal users" + ) + ); + } + + const parsed = deleteMyAccountBody.safeParse(req.body ?? {}); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsed.error).toString() + ) + ); + } + const { password, code } = parsed.data; + + const userId = user.userId; + + const ownedOrgsRows = await db + .select({ + orgId: userOrgs.orgId + }) + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, userId), + eq(userOrgs.isOwner, true) + ) + ); + + const orgIds = ownedOrgsRows.map((r) => r.orgId); + + if (!password) { + const orgsWithNames = + orgIds.length > 0 + ? await db + .select({ + orgId: orgs.orgId, + name: orgs.name + }) + .from(orgs) + .where(inArray(orgs.orgId, orgIds)) + : []; + return response(res, { + data: { + preview: true, + orgs: orgsWithNames.map((o) => ({ + orgId: o.orgId, + name: o.name ?? "" + })), + twoFactorEnabled: user.twoFactorEnabled ?? false + }, + success: true, + error: false, + message: "Preview", + status: HttpCode.OK + }); + } + + const validPassword = await verifyPassword( + password, + user.passwordHash! + ); + if (!validPassword) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Invalid password") + ); + } + + if (user.twoFactorEnabled) { + if (!code) { + return response(res, { + data: { codeRequested: true }, + success: true, + error: false, + message: "Two-factor code required", + status: HttpCode.ACCEPTED + }); + } + const validOTP = await verifyTotpCode( + code, + user.twoFactorSecret!, + user.userId + ); + if (!validOTP) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "The two-factor code you entered is incorrect" + ) + ); + } + } + + const allDeletedNewtIds: string[] = []; + const allOlmsToTerminate: string[] = []; + + for (const row of ownedOrgsRows) { + try { + const result = await deleteOrgById(row.orgId); + allDeletedNewtIds.push(...result.deletedNewtIds); + allOlmsToTerminate.push(...result.olmsToTerminate); + } catch (err) { + logger.error( + `Failed to delete org ${row.orgId} during account deletion`, + err + ); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to delete organization" + ) + ); + } + } + + sendTerminationMessages({ + deletedNewtIds: allDeletedNewtIds, + olmsToTerminate: allOlmsToTerminate + }); + + await db.transaction(async (trx) => { + await trx.delete(users).where(eq(users.userId, userId)); + await calculateUserClientsForOrgs(userId, trx); + }); + + try { + await invalidateSession(session.sessionId); + } catch (error) { + logger.error( + "Failed to invalidate session after account deletion", + error + ); + } + + const isSecure = req.protocol === "https"; + res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure)); + + return response(res, { + data: { success: true }, + success: true, + error: false, + message: "Account deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred" + ) + ); + } +} diff --git a/server/routers/auth/index.ts b/server/routers/auth/index.ts index ee08d155..7a469aa1 100644 --- a/server/routers/auth/index.ts +++ b/server/routers/auth/index.ts @@ -17,4 +17,5 @@ export * from "./securityKey"; export * from "./startDeviceWebAuth"; export * from "./verifyDeviceWebAuth"; export * from "./pollDeviceWebAuth"; -export * from "./lookupUser"; \ No newline at end of file +export * from "./lookupUser"; +export * from "./deleteMyAccount"; \ No newline at end of file diff --git a/server/routers/external.ts b/server/routers/external.ts index 52aaa81e..5d25e898 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -1171,6 +1171,7 @@ authRouter.post( auth.login ); authRouter.post("/logout", auth.logout); +authRouter.post("/delete-my-account", auth.deleteMyAccount); authRouter.post( "/lookup-user", rateLimit({ diff --git a/server/routers/org/deleteOrg.ts b/server/routers/org/deleteOrg.ts index 48d3102d..0e5b87a2 100644 --- a/server/routers/org/deleteOrg.ts +++ b/server/routers/org/deleteOrg.ts @@ -1,28 +1,12 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { - clients, - clientSiteResourcesAssociationsCache, - clientSitesAssociationsCache, - db, - domains, - olms, - orgDomains, - resources -} from "@server/db"; -import { newts, newtSessions, orgs, sites, userActions } from "@server/db"; -import { eq, and, inArray, sql } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; -import { sendToClient } from "#dynamic/routers/ws"; -import { deletePeer } from "../gerbil/peers"; import { OpenAPITags, registry } from "@server/openApi"; -import { OlmErrorCodes } from "../olm/error"; -import { sendTerminateClient } from "../client/terminate"; +import { deleteOrgById, sendTerminationMessages } from "@server/lib/deleteOrg"; const deleteOrgSchema = z.strictObject({ orgId: z.string() @@ -56,170 +40,9 @@ export async function deleteOrg( ) ); } - const { orgId } = parsedParams.data; - - const [org] = await db - .select() - .from(orgs) - .where(eq(orgs.orgId, orgId)) - .limit(1); - - if (!org) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Organization with ID ${orgId} not found` - ) - ); - } - // we need to handle deleting each site - const orgSites = await db - .select() - .from(sites) - .where(eq(sites.orgId, orgId)) - .limit(1); - - const orgClients = await db - .select() - .from(clients) - .where(eq(clients.orgId, orgId)); - - const deletedNewtIds: string[] = []; - const olmsToTerminate: string[] = []; - - await db.transaction(async (trx) => { - for (const site of orgSites) { - if (site.pubKey) { - if (site.type == "wireguard") { - await deletePeer(site.exitNodeId!, site.pubKey); - } else if (site.type == "newt") { - // get the newt on the site by querying the newt table for siteId - const [deletedNewt] = await trx - .delete(newts) - .where(eq(newts.siteId, site.siteId)) - .returning(); - if (deletedNewt) { - deletedNewtIds.push(deletedNewt.newtId); - - // delete all of the sessions for the newt - await trx - .delete(newtSessions) - .where( - eq(newtSessions.newtId, deletedNewt.newtId) - ); - } - } - } - - logger.info(`Deleting site ${site.siteId}`); - await trx.delete(sites).where(eq(sites.siteId, site.siteId)); - } - for (const client of orgClients) { - const [olm] = await trx - .select() - .from(olms) - .where(eq(olms.clientId, client.clientId)) - .limit(1); - - if (olm) { - olmsToTerminate.push(olm.olmId); - } - - logger.info(`Deleting client ${client.clientId}`); - await trx - .delete(clients) - .where(eq(clients.clientId, client.clientId)); - - // also delete the associations - await trx - .delete(clientSiteResourcesAssociationsCache) - .where( - eq( - clientSiteResourcesAssociationsCache.clientId, - client.clientId - ) - ); - - await trx - .delete(clientSitesAssociationsCache) - .where( - eq( - clientSitesAssociationsCache.clientId, - client.clientId - ) - ); - } - - const allOrgDomains = await trx - .select() - .from(orgDomains) - .innerJoin(domains, eq(domains.domainId, orgDomains.domainId)) - .where( - and( - eq(orgDomains.orgId, orgId), - eq(domains.configManaged, false) - ) - ); - - // For each domain, check if it belongs to multiple organizations - const domainIdsToDelete: string[] = []; - for (const orgDomain of allOrgDomains) { - const domainId = orgDomain.domains.domainId; - - // Count how many organizations this domain belongs to - const orgCount = await trx - .select({ count: sql`count(*)` }) - .from(orgDomains) - .where(eq(orgDomains.domainId, domainId)); - - // Only delete the domain if it belongs to exactly 1 organization (the one being deleted) - if (orgCount[0].count === 1) { - domainIdsToDelete.push(domainId); - } - } - - // Delete domains that belong exclusively to this organization - if (domainIdsToDelete.length > 0) { - await trx - .delete(domains) - .where(inArray(domains.domainId, domainIdsToDelete)); - } - - // Delete resources - await trx.delete(resources).where(eq(resources.orgId, orgId)); - - await trx.delete(orgs).where(eq(orgs.orgId, orgId)); - }); - - // Send termination messages outside of transaction to prevent blocking - for (const newtId of deletedNewtIds) { - const payload = { - type: `newt/wg/terminate`, - data: {} - }; - // Don't await this to prevent blocking the response - sendToClient(newtId, payload).catch((error) => { - logger.error( - "Failed to send termination message to newt:", - error - ); - }); - } - - for (const olmId of olmsToTerminate) { - sendTerminateClient( - 0, // clientId not needed since we're passing olmId - OlmErrorCodes.TERMINATED_REKEYED, - olmId - ).catch((error) => { - logger.error( - "Failed to send termination message to olm:", - error - ); - }); - } - + const result = await deleteOrgById(orgId); + sendTerminationMessages(result); return response(res, { data: null, success: true, @@ -228,6 +51,9 @@ export async function deleteOrg( status: HttpCode.OK }); } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } logger.error(error); return next( createHttpError( diff --git a/src/app/auth/delete-account/DeleteAccountClient.tsx b/src/app/auth/delete-account/DeleteAccountClient.tsx new file mode 100644 index 00000000..8cd150af --- /dev/null +++ b/src/app/auth/delete-account/DeleteAccountClient.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { Button } from "@app/components/ui/button"; +import DeleteAccountConfirmDialog from "@app/components/DeleteAccountConfirmDialog"; +import UserProfileCard from "@app/components/UserProfileCard"; +import { ArrowLeft } from "lucide-react"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/api"; + +type DeleteAccountClientProps = { + displayName: string; +}; + +export default function DeleteAccountClient({ + displayName +}: DeleteAccountClientProps) { + const router = useRouter(); + const t = useTranslations(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const [isDialogOpen, setIsDialogOpen] = useState(false); + + function handleUseDifferentAccount() { + api.post("/auth/logout") + .catch((e) => { + console.error(t("logoutError"), e); + toast({ + title: t("logoutError"), + description: formatAxiosError(e, t("logoutError")) + }); + }) + .then(() => { + router.push( + "/auth/login?internal_redirect=/auth/delete-account" + ); + router.refresh(); + }); + } + + return ( +
+ +

+ {t("deleteAccountDescription")} +

+
+ + +
+ +
+ ); +} diff --git a/src/app/auth/delete-account/page.tsx b/src/app/auth/delete-account/page.tsx new file mode 100644 index 00000000..5cbc8d73 --- /dev/null +++ b/src/app/auth/delete-account/page.tsx @@ -0,0 +1,28 @@ +import { verifySession } from "@app/lib/auth/verifySession"; +import { redirect } from "next/navigation"; +import { build } from "@server/build"; +import { cache } from "react"; +import DeleteAccountClient from "./DeleteAccountClient"; +import { getTranslations } from "next-intl/server"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; + +export const dynamic = "force-dynamic"; + +export default async function DeleteAccountPage() { + const getUser = cache(verifySession); + const user = await getUser({ skipCheckVerifyEmail: true }); + + if (!user) { + redirect("/auth/login"); + } + + const t = await getTranslations(); + const displayName = getUserDisplayName({ user }); + + return ( +
+

{t("deleteAccount")}

+ +
+ ); +} diff --git a/src/components/ApplyInternalRedirect.tsx b/src/components/ApplyInternalRedirect.tsx index f2afc8cb..24e93336 100644 --- a/src/components/ApplyInternalRedirect.tsx +++ b/src/components/ApplyInternalRedirect.tsx @@ -2,7 +2,7 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; -import { consumeInternalRedirectPath } from "@app/lib/internalRedirect"; +import { getInternalRedirectTarget } from "@app/lib/internalRedirect"; type ApplyInternalRedirectProps = { orgId: string; @@ -14,9 +14,9 @@ export default function ApplyInternalRedirect({ const router = useRouter(); useEffect(() => { - const path = consumeInternalRedirectPath(); - if (path) { - router.replace(`/${orgId}${path}`); + const target = getInternalRedirectTarget(orgId); + if (target) { + router.replace(target); } }, [orgId, router]); diff --git a/src/components/DeleteAccountConfirmDialog.tsx b/src/components/DeleteAccountConfirmDialog.tsx new file mode 100644 index 00000000..7a54f9a0 --- /dev/null +++ b/src/components/DeleteAccountConfirmDialog.tsx @@ -0,0 +1,414 @@ +"use client"; + +import { useState, useEffect, useMemo } from "react"; +import { useRouter } from "next/navigation"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { formatAxiosError } from "@app/lib/api"; +import { toast } from "@app/hooks/useToast"; +import { useTranslations } from "next-intl"; +import { Button } from "@app/components/ui/button"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot +} from "@app/components/ui/input-otp"; +import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp"; +import type { + DeleteMyAccountPreviewResponse, + DeleteMyAccountCodeRequestedResponse, + DeleteMyAccountSuccessResponse +} from "@server/routers/auth/deleteMyAccount"; +import { AxiosResponse } from "axios"; + +type DeleteAccountConfirmDialogProps = { + open: boolean; + setOpen: (open: boolean) => void; +}; + +export default function DeleteAccountConfirmDialog({ + open, + setOpen +}: DeleteAccountConfirmDialogProps) { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const router = useRouter(); + const t = useTranslations(); + + const passwordSchema = useMemo( + () => + z.object({ + password: z.string().min(1, { message: t("passwordRequired") }) + }), + [t] + ); + + const codeSchema = useMemo( + () => + z.object({ + code: z.string().length(6, { message: t("pincodeInvalid") }) + }), + [t] + ); + + const [step, setStep] = useState<0 | 1 | 2>(0); + const [loading, setLoading] = useState(false); + const [loadingPreview, setLoadingPreview] = useState(false); + const [preview, setPreview] = + useState(null); + const [passwordValue, setPasswordValue] = useState(""); + + const passwordForm = useForm>({ + resolver: zodResolver(passwordSchema), + defaultValues: { password: "" } + }); + + const codeForm = useForm>({ + resolver: zodResolver(codeSchema), + defaultValues: { code: "" } + }); + + useEffect(() => { + if (open && step === 0 && !preview) { + setLoadingPreview(true); + api.post>( + "/auth/delete-my-account", + {} + ) + .then((res) => { + if (res.data?.data?.preview) { + setPreview(res.data.data); + } + }) + .catch((err) => { + toast({ + variant: "destructive", + title: t("deleteAccountError"), + description: formatAxiosError( + err, + t("deleteAccountError") + ) + }); + setOpen(false); + }) + .finally(() => setLoadingPreview(false)); + } + }, [open, step, preview, api, setOpen, t]); + + function reset() { + setStep(0); + setPreview(null); + setPasswordValue(""); + passwordForm.reset(); + codeForm.reset(); + } + + async function handleContinueToPassword() { + setStep(1); + } + + async function handlePasswordSubmit( + values: z.infer + ) { + setLoading(true); + setPasswordValue(values.password); + try { + const res = await api.post< + | AxiosResponse + | AxiosResponse + >("/auth/delete-my-account", { password: values.password }); + + const data = res.data?.data; + + if (data && "codeRequested" in data && data.codeRequested) { + setStep(2); + } else if (data && "success" in data && data.success) { + toast({ + title: t("deleteAccountSuccess"), + description: t("deleteAccountSuccessMessage") + }); + setOpen(false); + reset(); + router.push("/auth/login"); + router.refresh(); + } + } catch (err) { + toast({ + variant: "destructive", + title: t("deleteAccountError"), + description: formatAxiosError(err, t("deleteAccountError")) + }); + } finally { + setLoading(false); + } + } + + async function handleCodeSubmit(values: z.infer) { + setLoading(true); + try { + const res = await api.post< + AxiosResponse + >("/auth/delete-my-account", { + password: passwordValue, + code: values.code + }); + + if (res.data?.data?.success) { + toast({ + title: t("deleteAccountSuccess"), + description: t("deleteAccountSuccessMessage") + }); + setOpen(false); + reset(); + router.push("/auth/login"); + router.refresh(); + } + } catch (err) { + toast({ + variant: "destructive", + title: t("deleteAccountError"), + description: formatAxiosError(err, t("deleteAccountError")) + }); + } finally { + setLoading(false); + } + } + + return ( + { + setOpen(val); + if (!val) reset(); + }} + > + + + + {t("deleteAccountConfirmTitle")} + + + +
+ {step === 0 && ( + <> + {loadingPreview ? ( +

+ {t("loading")}... +

+ ) : preview ? ( + <> +

+ {t("deleteAccountConfirmMessage")} +

+
+

+ {t( + "deleteAccountPreviewAccount" + )} +

+ {preview.orgs.length > 0 && ( + <> +

+ {t( + "deleteAccountPreviewOrgs" + )} +

+
    + {preview.orgs.map( + (org) => ( +
  • + {org.name || + org.orgId} +
  • + ) + )} +
+ + )} +
+

+ {t("cannotbeUndone")} +

+ + ) : null} + + )} + + {step === 1 && ( +
+ + ( + + + {t("password")} + + + + + + + )} + /> + + + )} + + {step === 2 && ( +
+
+

+ {t("otpAuthDescription")} +

+
+
+ + ( + + +
+ { + field.onChange( + value + ); + }} + > + + + + + + + + + +
+
+ +
+ )} + /> + + +
+ )} +
+
+ + + + + {step === 0 && preview && !loadingPreview && ( + + )} + {step === 1 && ( + + )} + {step === 2 && ( + + )} + +
+
+ ); +} diff --git a/src/components/ProfileIcon.tsx b/src/components/ProfileIcon.tsx index d466b707..4c900c62 100644 --- a/src/components/ProfileIcon.tsx +++ b/src/components/ProfileIcon.tsx @@ -15,9 +15,11 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { formatAxiosError } from "@app/lib/api"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; -import { Laptop, LogOut, Moon, Sun, Smartphone } from "lucide-react"; +import { Laptop, LogOut, Moon, Sun, Smartphone, Trash2 } from "lucide-react"; import { useTheme } from "next-themes"; import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { build } from "@server/build"; import { useState } from "react"; import { useUserContext } from "@app/hooks/useUserContext"; import Disable2FaForm from "./Disable2FaForm"; @@ -187,6 +189,20 @@ export default function ProfileIcon() { + {user?.type === UserType.Internal && !user?.serverAdmin && ( + <> + + + + {t("deleteAccount")} + + + + + )} logout()}> {/* */} {t("logout")} diff --git a/src/components/RedirectToOrg.tsx b/src/components/RedirectToOrg.tsx index 7ea1ea4b..e647ee7a 100644 --- a/src/components/RedirectToOrg.tsx +++ b/src/components/RedirectToOrg.tsx @@ -13,7 +13,8 @@ export default function RedirectToOrg({ targetOrgId }: RedirectToOrgProps) { useEffect(() => { try { - const target = getInternalRedirectTarget(targetOrgId); + const target = + getInternalRedirectTarget(targetOrgId) ?? `/${targetOrgId}`; router.replace(target); } catch { router.replace(`/${targetOrgId}`); diff --git a/src/lib/internalRedirect.ts b/src/lib/internalRedirect.ts index 115cea5c..6514db66 100644 --- a/src/lib/internalRedirect.ts +++ b/src/lib/internalRedirect.ts @@ -41,11 +41,12 @@ export function consumeInternalRedirectPath(): string | null { } /** - * Returns the full redirect target for an org: either `/${orgId}` or - * `/${orgId}${path}` if a valid internal_redirect was stored. Consumes the - * stored value. + * Returns the full redirect target if a valid internal_redirect was stored + * (consumes the stored value). Returns null if none was stored or expired. + * Paths starting with /auth/ are returned as-is; others are prefixed with orgId. */ -export function getInternalRedirectTarget(orgId: string): string { +export function getInternalRedirectTarget(orgId: string): string | null { const path = consumeInternalRedirectPath(); - return path ? `/${orgId}${path}` : `/${orgId}`; + if (!path) return null; + return path.startsWith("/auth/") ? path : `/${orgId}${path}`; }