refactor: implement user deletion functionality and enhance settings management

This commit is contained in:
Abhimanyu Saharan
2026-02-10 00:17:06 +05:30
parent d9f560ee0c
commit 55d4c482bc
11 changed files with 843 additions and 41 deletions

View File

@@ -20,13 +20,19 @@ import type {
UseQueryResult,
} from "@tanstack/react-query";
import type { HTTPValidationError, UserRead, UserUpdate } from ".././model";
import type {
HTTPValidationError,
OkResponse,
UserRead,
UserUpdate,
} from ".././model";
import { customFetch } from "../../mutator";
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
/**
* Return the authenticated user's current profile payload.
* @summary Get Me
*/
export type getMeApiV1UsersMeGetResponse200 = {
@@ -196,6 +202,109 @@ export function useGetMeApiV1UsersMeGet<
}
/**
* Delete the authenticated account and any personal-only organizations.
* @summary Delete Me
*/
export type deleteMeApiV1UsersMeDeleteResponse200 = {
data: OkResponse;
status: 200;
};
export type deleteMeApiV1UsersMeDeleteResponseSuccess =
deleteMeApiV1UsersMeDeleteResponse200 & {
headers: Headers;
};
export type deleteMeApiV1UsersMeDeleteResponse =
deleteMeApiV1UsersMeDeleteResponseSuccess;
export const getDeleteMeApiV1UsersMeDeleteUrl = () => {
return `/api/v1/users/me`;
};
export const deleteMeApiV1UsersMeDelete = async (
options?: RequestInit,
): Promise<deleteMeApiV1UsersMeDeleteResponse> => {
return customFetch<deleteMeApiV1UsersMeDeleteResponse>(
getDeleteMeApiV1UsersMeDeleteUrl(),
{
...options,
method: "DELETE",
},
);
};
export const getDeleteMeApiV1UsersMeDeleteMutationOptions = <
TError = unknown,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteMeApiV1UsersMeDelete>>,
TError,
void,
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteMeApiV1UsersMeDelete>>,
TError,
void,
TContext
> => {
const mutationKey = ["deleteMeApiV1UsersMeDelete"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteMeApiV1UsersMeDelete>>,
void
> = () => {
return deleteMeApiV1UsersMeDelete(requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteMeApiV1UsersMeDeleteMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteMeApiV1UsersMeDelete>>
>;
export type DeleteMeApiV1UsersMeDeleteMutationError = unknown;
/**
* @summary Delete Me
*/
export const useDeleteMeApiV1UsersMeDelete = <
TError = unknown,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteMeApiV1UsersMeDelete>>,
TError,
void,
TContext
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof deleteMeApiV1UsersMeDelete>>,
TError,
void,
TContext
> => {
return useMutation(
getDeleteMeApiV1UsersMeDeleteMutationOptions(options),
queryClient,
);
};
/**
* Apply partial profile updates for the authenticated user.
* @summary Update Me
*/
export type updateMeApiV1UsersMePatchResponse200 = {

View File

@@ -247,7 +247,7 @@ export default function DashboardPage() {
dashboardMetricsApiV1MetricsDashboardGetResponse,
ApiError
>(
{ range: "24h" },
{ range_key: "24h" },
{
query: {
enabled: Boolean(isSignedIn),

View File

@@ -69,14 +69,14 @@ export default function GatewayDetailPage() {
gateway_token: gateway.token ?? undefined,
gateway_main_session_key: gateway.main_session_key ?? undefined,
}
: undefined;
: {};
const statusQuery = useGatewaysStatusApiV1GatewaysStatusGet<
gatewaysStatusApiV1GatewaysStatusGetResponse,
ApiError
>(statusParams, {
query: {
enabled: Boolean(isSignedIn && isAdmin && statusParams),
enabled: Boolean(isSignedIn && isAdmin && gateway),
refetchInterval: 15_000,
},
});

View File

@@ -25,6 +25,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import SearchableSelect from "@/components/ui/searchable-select";
import { isOnboardingComplete } from "@/lib/onboarding";
import { getSupportedTimezones } from "@/lib/timezones";
export default function OnboardingPage() {
const router = useRouter();
@@ -76,30 +77,7 @@ export default function OnboardingPage() {
[resolvedName, resolvedTimezone],
);
const timezones = useMemo(() => {
if (typeof Intl !== "undefined" && "supportedValuesOf" in Intl) {
return (
Intl as typeof Intl & { supportedValuesOf: (key: string) => string[] }
)
.supportedValuesOf("timeZone")
.sort();
}
return [
"America/Los_Angeles",
"America/Denver",
"America/Chicago",
"America/New_York",
"America/Sao_Paulo",
"Europe/London",
"Europe/Berlin",
"Europe/Paris",
"Asia/Dubai",
"Asia/Kolkata",
"Asia/Singapore",
"Asia/Tokyo",
"Australia/Sydney",
];
}, []);
const timezones = useMemo(() => getSupportedTimezones(), []);
const timezoneOptions = useMemo(
() => timezones.map((tz) => ({ value: tz, label: tz })),

View File

@@ -0,0 +1,284 @@
"use client";
export const dynamic = "force-dynamic";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth, useUser } from "@/auth/clerk";
import { useQueryClient } from "@tanstack/react-query";
import { Globe, Mail, RotateCcw, Save, Trash2, User } from "lucide-react";
import {
useDeleteMeApiV1UsersMeDelete,
getGetMeApiV1UsersMeGetQueryKey,
type getMeApiV1UsersMeGetResponse,
useGetMeApiV1UsersMeGet,
useUpdateMeApiV1UsersMePatch,
} from "@/api/generated/users/users";
import { ApiError } from "@/api/mutator";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { Button } from "@/components/ui/button";
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
import { Input } from "@/components/ui/input";
import SearchableSelect from "@/components/ui/searchable-select";
import { getSupportedTimezones } from "@/lib/timezones";
type ClerkGlobal = {
signOut?: (options?: { redirectUrl?: string }) => Promise<void> | void;
};
export default function SettingsPage() {
const router = useRouter();
const queryClient = useQueryClient();
const { isSignedIn } = useAuth();
const { user } = useUser();
const [name, setName] = useState("");
const [timezone, setTimezone] = useState<string | null>(null);
const [nameEdited, setNameEdited] = useState(false);
const [timezoneEdited, setTimezoneEdited] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const meQuery = useGetMeApiV1UsersMeGet<
getMeApiV1UsersMeGetResponse,
ApiError
>({
query: {
enabled: Boolean(isSignedIn),
retry: false,
refetchOnMount: "always",
},
});
const meQueryKey = getGetMeApiV1UsersMeGetQueryKey();
const profile = meQuery.data?.status === 200 ? meQuery.data.data : null;
const clerkFallbackName =
user?.fullName ?? user?.firstName ?? user?.username ?? "";
const displayEmail =
profile?.email ?? user?.primaryEmailAddress?.emailAddress ?? "";
const resolvedName = nameEdited
? name
: (profile?.name ?? profile?.preferred_name ?? clerkFallbackName);
const resolvedTimezone = timezoneEdited
? (timezone ?? "")
: (profile?.timezone ?? "");
const timezones = useMemo(() => getSupportedTimezones(), []);
const timezoneOptions = useMemo(
() => timezones.map((value) => ({ value, label: value })),
[timezones],
);
const updateMeMutation = useUpdateMeApiV1UsersMePatch<ApiError>({
mutation: {
onSuccess: async () => {
setSaveError(null);
setSaveSuccess("Settings saved.");
await queryClient.invalidateQueries({ queryKey: meQueryKey });
},
onError: (error) => {
setSaveSuccess(null);
setSaveError(error.message || "Unable to save settings.");
},
},
});
const deleteAccountMutation = useDeleteMeApiV1UsersMeDelete<ApiError>({
mutation: {
onSuccess: async () => {
setDeleteError(null);
if (typeof window !== "undefined") {
const clerk = (window as Window & { Clerk?: ClerkGlobal }).Clerk;
if (clerk?.signOut) {
try {
await clerk.signOut({ redirectUrl: "/sign-in" });
return;
} catch {
// Fall through to local redirect.
}
}
}
router.replace("/sign-in");
},
onError: (error) => {
setDeleteError(error.message || "Unable to delete account.");
},
},
});
const handleSave = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!isSignedIn) return;
if (!resolvedName.trim() || !resolvedTimezone.trim()) {
setSaveSuccess(null);
setSaveError("Name and timezone are required.");
return;
}
setSaveError(null);
setSaveSuccess(null);
await updateMeMutation.mutateAsync({
data: {
name: resolvedName.trim(),
timezone: resolvedTimezone.trim(),
},
});
};
const handleReset = () => {
setName("");
setTimezone(null);
setNameEdited(false);
setTimezoneEdited(false);
setSaveError(null);
setSaveSuccess(null);
};
const isSaving = updateMeMutation.isPending;
return (
<>
<DashboardPageLayout
signedOut={{
message: "Sign in to manage your settings.",
forceRedirectUrl: "/settings",
signUpForceRedirectUrl: "/settings",
}}
title="Settings"
description="Update your profile and account preferences."
>
<div className="space-y-6">
<section className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 className="text-base font-semibold text-slate-900">Profile</h2>
<p className="mt-1 text-sm text-slate-500">
Keep your identity and timezone up to date.
</p>
<form onSubmit={handleSave} className="mt-6 space-y-5">
<div className="grid gap-5 md:grid-cols-2">
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
<User className="h-4 w-4 text-slate-500" />
Name
</label>
<Input
value={resolvedName}
onChange={(event) => {
setName(event.target.value);
setNameEdited(true);
}}
placeholder="Your name"
disabled={isSaving}
className="border-slate-300 text-slate-900 focus-visible:ring-blue-500"
/>
</div>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
<Globe className="h-4 w-4 text-slate-500" />
Timezone
</label>
<SearchableSelect
ariaLabel="Select timezone"
value={resolvedTimezone}
onValueChange={(value) => {
setTimezone(value);
setTimezoneEdited(true);
}}
options={timezoneOptions}
placeholder="Select timezone"
searchPlaceholder="Search timezones..."
emptyMessage="No matching timezones."
disabled={isSaving}
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
contentClassName="rounded-xl border border-slate-200 shadow-lg"
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
/>
</div>
</div>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
<Mail className="h-4 w-4 text-slate-500" />
Email
</label>
<Input
value={displayEmail}
readOnly
disabled
className="border-slate-200 bg-slate-50 text-slate-600"
/>
</div>
{saveError ? (
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">
{saveError}
</div>
) : null}
{saveSuccess ? (
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-700">
{saveSuccess}
</div>
) : null}
<div className="flex flex-wrap gap-3">
<Button type="submit" disabled={isSaving}>
<Save className="h-4 w-4" />
{isSaving ? "Saving…" : "Save settings"}
</Button>
<Button
type="button"
variant="outline"
onClick={handleReset}
disabled={isSaving}
>
<RotateCcw className="h-4 w-4" />
Reset
</Button>
</div>
</form>
</section>
<section className="rounded-xl border border-rose-200 bg-rose-50/70 p-6 shadow-sm">
<h2 className="text-base font-semibold text-rose-900">
Delete account
</h2>
<p className="mt-1 text-sm text-rose-800">
This permanently removes your Mission Control account and related
personal data. This action cannot be undone.
</p>
<div className="mt-4">
<Button
type="button"
className="bg-rose-600 text-white hover:bg-rose-700"
onClick={() => {
setDeleteError(null);
setDeleteDialogOpen(true);
}}
disabled={deleteAccountMutation.isPending}
>
<Trash2 className="h-4 w-4" />
Delete account
</Button>
</div>
</section>
</div>
</DashboardPageLayout>
<ConfirmActionDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title="Delete your account?"
description="Your account and personal data will be permanently deleted."
onConfirm={() => deleteAccountMutation.mutate()}
isConfirming={deleteAccountMutation.isPending}
errorMessage={deleteError}
confirmLabel="Delete account"
confirmingLabel="Deleting account…"
ariaLabel="Delete account confirmation"
/>
</>
);
}

View File

@@ -12,6 +12,7 @@ import {
LogOut,
Plus,
Server,
Settings,
Trello,
} from "lucide-react";
@@ -22,17 +23,26 @@ import {
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
export function UserMenu({ className }: { className?: string }) {
type UserMenuProps = {
className?: string;
displayName?: string;
displayEmail?: string;
};
export function UserMenu({
className,
displayName: displayNameFromDb,
displayEmail: displayEmailFromDb,
}: UserMenuProps) {
const [open, setOpen] = useState(false);
const { user } = useUser();
if (!user) return null;
const avatarUrl = user.imageUrl ?? null;
const avatarLabelSource = user.firstName ?? user.username ?? user.id ?? "U";
const avatarLabelSource = displayNameFromDb ?? user.id ?? "U";
const avatarLabel = avatarLabelSource.slice(0, 1).toUpperCase();
const displayName =
user.fullName ?? user.firstName ?? user.username ?? "Account";
const displayEmail = user.primaryEmailAddress?.emailAddress ?? "";
const displayName = displayNameFromDb ?? "Account";
const displayEmail = displayEmailFromDb ?? "";
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -140,6 +150,7 @@ export function UserMenu({ className }: { className?: string }) {
{ href: "/activity", label: "Activity", icon: Activity },
{ href: "/agents", label: "Agents", icon: Bot },
{ href: "/gateways", label: "Gateways", icon: Server },
{ href: "/settings", label: "Settings", icon: Settings },
] as const
).map((item) => (
<Link

View File

@@ -4,7 +4,7 @@ import { useEffect } from "react";
import type { ReactNode } from "react";
import { usePathname, useRouter } from "next/navigation";
import { SignedIn, useAuth, useUser } from "@/auth/clerk";
import { SignedIn, useAuth } from "@/auth/clerk";
import { ApiError } from "@/api/mutator";
import {
@@ -20,9 +20,6 @@ export function DashboardShell({ children }: { children: ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const { isSignedIn } = useAuth();
const { user } = useUser();
const displayName =
user?.fullName ?? user?.firstName ?? user?.username ?? "Operator";
const isOnboardingPath = pathname === "/onboarding";
const meQuery = useGetMeApiV1UsersMeGet<
@@ -36,6 +33,8 @@ export function DashboardShell({ children }: { children: ReactNode }) {
},
});
const profile = meQuery.data?.status === 200 ? meQuery.data.data : null;
const displayName = profile?.name ?? profile?.preferred_name ?? "Operator";
const displayEmail = profile?.email ?? "";
useEffect(() => {
if (!isSignedIn || isOnboardingPath) return;
@@ -91,7 +90,7 @@ export function DashboardShell({ children }: { children: ReactNode }) {
</p>
<p className="text-xs text-slate-500">Operator</p>
</div>
<UserMenu />
<UserMenu displayName={displayName} displayEmail={displayEmail} />
</div>
</SignedIn>
</div>

View File

@@ -0,0 +1,26 @@
export const fallbackTimezones = [
"America/Los_Angeles",
"America/Denver",
"America/Chicago",
"America/New_York",
"America/Sao_Paulo",
"Europe/London",
"Europe/Berlin",
"Europe/Paris",
"Asia/Dubai",
"Asia/Kolkata",
"Asia/Singapore",
"Asia/Tokyo",
"Australia/Sydney",
];
export function getSupportedTimezones(): string[] {
if (typeof Intl !== "undefined" && "supportedValuesOf" in Intl) {
return (
Intl as typeof Intl & { supportedValuesOf: (key: string) => string[] }
)
.supportedValuesOf("timeZone")
.sort();
}
return fallbackTimezones;
}