From 55d4c482bc70993ec77eb2dbb56b952b126711d0 Mon Sep 17 00:00:00 2001
From: Abhimanyu Saharan
Date: Tue, 10 Feb 2026 00:17:06 +0530
Subject: [PATCH] refactor: implement user deletion functionality and enhance
settings management
---
backend/app/api/users.py | 242 ++++++++++++++-
backend/app/core/auth.py | 61 ++++
backend/tests/test_users_delete_api.py | 98 ++++++
frontend/src/api/generated/users/users.ts | 111 ++++++-
frontend/src/app/dashboard/page.tsx | 2 +-
.../src/app/gateways/[gatewayId]/page.tsx | 4 +-
frontend/src/app/onboarding/page.tsx | 26 +-
frontend/src/app/settings/page.tsx | 284 ++++++++++++++++++
.../src/components/organisms/UserMenu.tsx | 21 +-
.../components/templates/DashboardShell.tsx | 9 +-
frontend/src/lib/timezones.ts | 26 ++
11 files changed, 843 insertions(+), 41 deletions(-)
create mode 100644 backend/tests/test_users_delete_api.py
create mode 100644 frontend/src/app/settings/page.tsx
create mode 100644 frontend/src/lib/timezones.ts
diff --git a/backend/app/api/users.py b/backend/app/api/users.py
index 2ab8b22..8337144 100644
--- a/backend/app/api/users.py
+++ b/backend/app/api/users.py
@@ -3,23 +3,191 @@
from __future__ import annotations
from typing import TYPE_CHECKING
+from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
+from sqlmodel import col, select
-from app.core.auth import AuthContext, get_auth_context
+from app.core.auth import AuthContext, delete_clerk_user, get_auth_context
+from app.db import crud
from app.db.session import get_session
+from app.models.activity_events import ActivityEvent
+from app.models.agents import Agent
+from app.models.approvals import Approval
+from app.models.board_group_memory import BoardGroupMemory
+from app.models.board_groups import BoardGroup
+from app.models.board_memory import BoardMemory
+from app.models.board_onboarding import BoardOnboardingSession
+from app.models.boards import Board
+from app.models.gateways import Gateway
+from app.models.organization_board_access import OrganizationBoardAccess
+from app.models.organization_invite_board_access import OrganizationInviteBoardAccess
+from app.models.organization_invites import OrganizationInvite
+from app.models.organization_members import OrganizationMember
+from app.models.organizations import Organization
+from app.models.task_dependencies import TaskDependency
+from app.models.task_fingerprints import TaskFingerprint
+from app.models.tasks import Task
+from app.models.users import User
+from app.schemas.common import OkResponse
from app.schemas.users import UserRead, UserUpdate
if TYPE_CHECKING:
from sqlmodel.ext.asyncio.session import AsyncSession
- from app.models.users import User
-
router = APIRouter(prefix="/users", tags=["users"])
AUTH_CONTEXT_DEP = Depends(get_auth_context)
SESSION_DEP = Depends(get_session)
+async def _delete_organization_tree(
+ session: AsyncSession,
+ *,
+ organization_id: UUID,
+) -> None:
+ """Delete an organization and dependent rows without committing."""
+ board_ids = select(Board.id).where(col(Board.organization_id) == organization_id)
+ task_ids = select(Task.id).where(col(Task.board_id).in_(board_ids))
+ agent_ids = select(Agent.id).where(col(Agent.board_id).in_(board_ids))
+ member_ids = select(OrganizationMember.id).where(
+ col(OrganizationMember.organization_id) == organization_id,
+ )
+ invite_ids = select(OrganizationInvite.id).where(
+ col(OrganizationInvite.organization_id) == organization_id,
+ )
+ group_ids = select(BoardGroup.id).where(
+ col(BoardGroup.organization_id) == organization_id,
+ )
+
+ await crud.delete_where(
+ session,
+ ActivityEvent,
+ col(ActivityEvent.task_id).in_(task_ids),
+ commit=False,
+ )
+ await crud.delete_where(
+ session,
+ ActivityEvent,
+ col(ActivityEvent.agent_id).in_(agent_ids),
+ commit=False,
+ )
+ await crud.delete_where(
+ session,
+ TaskDependency,
+ col(TaskDependency.board_id).in_(board_ids),
+ commit=False,
+ )
+ await crud.delete_where(
+ session,
+ TaskFingerprint,
+ col(TaskFingerprint.board_id).in_(board_ids),
+ commit=False,
+ )
+ await crud.delete_where(
+ session,
+ Approval,
+ col(Approval.board_id).in_(board_ids),
+ commit=False,
+ )
+ await crud.delete_where(
+ session,
+ BoardMemory,
+ col(BoardMemory.board_id).in_(board_ids),
+ commit=False,
+ )
+ await crud.delete_where(
+ session,
+ BoardOnboardingSession,
+ col(BoardOnboardingSession.board_id).in_(board_ids),
+ commit=False,
+ )
+ await crud.delete_where(
+ session,
+ OrganizationBoardAccess,
+ col(OrganizationBoardAccess.board_id).in_(board_ids),
+ commit=False,
+ )
+ await crud.delete_where(
+ session,
+ OrganizationInviteBoardAccess,
+ col(OrganizationInviteBoardAccess.board_id).in_(board_ids),
+ commit=False,
+ )
+ await crud.delete_where(
+ session,
+ OrganizationBoardAccess,
+ col(OrganizationBoardAccess.organization_member_id).in_(member_ids),
+ commit=False,
+ )
+ await crud.delete_where(
+ session,
+ OrganizationInviteBoardAccess,
+ col(OrganizationInviteBoardAccess.organization_invite_id).in_(invite_ids),
+ commit=False,
+ )
+ await crud.delete_where(
+ session,
+ Task,
+ col(Task.board_id).in_(board_ids),
+ commit=False,
+ )
+ await crud.delete_where(
+ session,
+ Agent,
+ col(Agent.board_id).in_(board_ids),
+ commit=False,
+ )
+ await crud.delete_where(
+ session,
+ Board,
+ col(Board.organization_id) == organization_id,
+ commit=False,
+ )
+ await crud.delete_where(
+ session,
+ BoardGroupMemory,
+ col(BoardGroupMemory.board_group_id).in_(group_ids),
+ commit=False,
+ )
+ await crud.delete_where(
+ session,
+ BoardGroup,
+ col(BoardGroup.organization_id) == organization_id,
+ commit=False,
+ )
+ await crud.delete_where(
+ session,
+ Gateway,
+ col(Gateway.organization_id) == organization_id,
+ commit=False,
+ )
+ await crud.delete_where(
+ session,
+ OrganizationInvite,
+ col(OrganizationInvite.organization_id) == organization_id,
+ commit=False,
+ )
+ await crud.delete_where(
+ session,
+ OrganizationMember,
+ col(OrganizationMember.organization_id) == organization_id,
+ commit=False,
+ )
+ await crud.update_where(
+ session,
+ User,
+ col(User.active_organization_id) == organization_id,
+ active_organization_id=None,
+ commit=False,
+ )
+ await crud.delete_where(
+ session,
+ Organization,
+ col(Organization.id) == organization_id,
+ commit=False,
+ )
+
+
@router.get("/me", response_model=UserRead)
async def get_me(auth: AuthContext = AUTH_CONTEXT_DEP) -> UserRead:
"""Return the authenticated user's current profile payload."""
@@ -45,3 +213,71 @@ async def update_me(
await session.commit()
await session.refresh(user)
return UserRead.model_validate(user)
+
+
+@router.delete("/me", response_model=OkResponse)
+async def delete_me(
+ session: AsyncSession = SESSION_DEP,
+ auth: AuthContext = AUTH_CONTEXT_DEP,
+) -> OkResponse:
+ """Delete the authenticated account and any personal-only organizations."""
+ if auth.actor_type != "user" or auth.user is None:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
+
+ user: User = auth.user
+ await delete_clerk_user(user.clerk_user_id)
+ memberships = await OrganizationMember.objects.filter_by(user_id=user.id).all(session)
+
+ await crud.update_where(
+ session,
+ OrganizationInvite,
+ col(OrganizationInvite.created_by_user_id) == user.id,
+ created_by_user_id=None,
+ commit=False,
+ )
+ await crud.update_where(
+ session,
+ OrganizationInvite,
+ col(OrganizationInvite.accepted_by_user_id) == user.id,
+ accepted_by_user_id=None,
+ commit=False,
+ )
+ await crud.update_where(
+ session,
+ Task,
+ col(Task.created_by_user_id) == user.id,
+ created_by_user_id=None,
+ commit=False,
+ )
+
+ for member in memberships:
+ org_members = await OrganizationMember.objects.filter_by(
+ organization_id=member.organization_id,
+ ).all(session)
+ if len(org_members) <= 1:
+ await _delete_organization_tree(
+ session,
+ organization_id=member.organization_id,
+ )
+ continue
+ await crud.delete_where(
+ session,
+ OrganizationBoardAccess,
+ col(OrganizationBoardAccess.organization_member_id) == member.id,
+ commit=False,
+ )
+ await crud.delete_where(
+ session,
+ OrganizationMember,
+ col(OrganizationMember.id) == member.id,
+ commit=False,
+ )
+
+ await crud.delete_where(
+ session,
+ User,
+ col(User.id) == user.id,
+ commit=False,
+ )
+ await session.commit()
+ return OkResponse()
diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py
index 2bb22e8..36f0dcf 100644
--- a/backend/app/core/auth.py
+++ b/backend/app/core/auth.py
@@ -332,6 +332,67 @@ async def _fetch_clerk_profile(clerk_user_id: str) -> tuple[str | None, str | No
return None, None
+async def delete_clerk_user(clerk_user_id: str) -> None:
+ """Delete a Clerk user via the official Clerk SDK."""
+ secret = settings.clerk_secret_key.strip()
+ secret_kind = secret.split("_", maxsplit=1)[0] if "_" in secret else "unknown"
+ server_url = _normalize_clerk_server_url(settings.clerk_api_url or "")
+
+ try:
+ async with Clerk(
+ bearer_auth=secret,
+ server_url=server_url,
+ timeout_ms=5000,
+ ) as clerk:
+ await clerk.users.delete_async(user_id=clerk_user_id)
+ logger.info("auth.clerk.user.delete clerk_user_id=%s", clerk_user_id)
+ except ClerkErrors as exc:
+ errors_payload = str(exc)
+ if len(errors_payload) > 300:
+ errors_payload = f"{errors_payload[:300]}..."
+ logger.warning(
+ "auth.clerk.user.delete_failed clerk_user_id=%s reason=clerk_errors "
+ "secret_kind=%s body=%s",
+ clerk_user_id,
+ secret_kind,
+ errors_payload,
+ )
+ raise HTTPException(
+ status_code=status.HTTP_502_BAD_GATEWAY,
+ detail="Failed to delete account from Clerk",
+ ) from exc
+ except SDKError as exc:
+ if exc.status_code == 404:
+ logger.info("auth.clerk.user.delete_missing clerk_user_id=%s", clerk_user_id)
+ return
+ response_body = exc.body.strip() or None
+ if response_body and len(response_body) > 300:
+ response_body = f"{response_body[:300]}..."
+ logger.warning(
+ "auth.clerk.user.delete_failed clerk_user_id=%s status=%s reason=sdk_error "
+ "server_url=%s secret_kind=%s body=%s",
+ clerk_user_id,
+ exc.status_code,
+ server_url,
+ secret_kind,
+ response_body,
+ )
+ raise HTTPException(
+ status_code=status.HTTP_502_BAD_GATEWAY,
+ detail="Failed to delete account from Clerk",
+ ) from exc
+ except Exception as exc:
+ logger.warning(
+ "auth.clerk.user.delete_failed clerk_user_id=%s reason=sdk_exception",
+ clerk_user_id,
+ exc_info=True,
+ )
+ raise HTTPException(
+ status_code=status.HTTP_502_BAD_GATEWAY,
+ detail="Failed to delete account from Clerk",
+ ) from exc
+
+
async def _get_or_sync_user(
session: AsyncSession,
*,
diff --git a/backend/tests/test_users_delete_api.py b/backend/tests/test_users_delete_api.py
new file mode 100644
index 0000000..c6d32d8
--- /dev/null
+++ b/backend/tests/test_users_delete_api.py
@@ -0,0 +1,98 @@
+# ruff: noqa: S101
+"""Tests for user self-delete API behavior."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+from uuid import uuid4
+
+import pytest
+from fastapi import HTTPException, status
+
+from app.api import users
+from app.core.auth import AuthContext
+from app.models.users import User
+
+
+@dataclass
+class _FakeSession:
+ committed: int = 0
+
+ async def commit(self) -> None:
+ self.committed += 1
+
+
+class _EmptyMembershipQuery:
+ async def all(self, _session: Any) -> list[Any]:
+ return []
+
+
+class _FakeOrganizationMemberModel:
+ class objects:
+ @staticmethod
+ def filter_by(**_kwargs: Any) -> _EmptyMembershipQuery:
+ return _EmptyMembershipQuery()
+
+
+@pytest.mark.asyncio
+async def test_delete_me_aborts_when_clerk_delete_fails(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Local deletion should not run if Clerk account deletion fails."""
+ session = _FakeSession()
+ user = User(id=uuid4(), clerk_user_id="user_123")
+ auth = AuthContext(actor_type="user", user=user)
+
+ async def _fail_delete(_clerk_user_id: str) -> None:
+ raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="clerk failure")
+
+ async def _unexpected_update(*_args: Any, **_kwargs: Any) -> int:
+ raise AssertionError("crud.update_where should not be called on Clerk failure")
+
+ async def _unexpected_delete(*_args: Any, **_kwargs: Any) -> int:
+ raise AssertionError("crud.delete_where should not be called on Clerk failure")
+
+ monkeypatch.setattr(users, "delete_clerk_user", _fail_delete)
+ monkeypatch.setattr(users.crud, "update_where", _unexpected_update)
+ monkeypatch.setattr(users.crud, "delete_where", _unexpected_delete)
+
+ with pytest.raises(HTTPException) as exc_info:
+ await users.delete_me(session=session, auth=auth)
+
+ assert exc_info.value.status_code == status.HTTP_502_BAD_GATEWAY
+ assert session.committed == 0
+
+
+@pytest.mark.asyncio
+async def test_delete_me_deletes_local_user_after_clerk_success(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """User delete should invoke Clerk deletion, then remove local account."""
+ session = _FakeSession()
+ user = User(id=uuid4(), clerk_user_id="user_456")
+ auth = AuthContext(actor_type="user", user=user)
+ calls: dict[str, int] = {"clerk": 0, "update": 0, "delete": 0}
+
+ async def _delete_from_clerk(clerk_user_id: str) -> None:
+ assert clerk_user_id == "user_456"
+ calls["clerk"] += 1
+
+ async def _update_where(*_args: Any, **_kwargs: Any) -> int:
+ calls["update"] += 1
+ return 0
+
+ async def _delete_where(*_args: Any, **_kwargs: Any) -> int:
+ calls["delete"] += 1
+ return 1
+
+ monkeypatch.setattr(users, "delete_clerk_user", _delete_from_clerk)
+ monkeypatch.setattr(users, "OrganizationMember", _FakeOrganizationMemberModel)
+ monkeypatch.setattr(users.crud, "update_where", _update_where)
+ monkeypatch.setattr(users.crud, "delete_where", _delete_where)
+
+ response = await users.delete_me(session=session, auth=auth)
+
+ assert response.ok is True
+ assert calls["clerk"] == 1
+ assert calls["update"] == 3
+ assert calls["delete"] == 1
+ assert session.committed == 1
diff --git a/frontend/src/api/generated/users/users.ts b/frontend/src/api/generated/users/users.ts
index abf3af8..cf2e0a0 100644
--- a/frontend/src/api/generated/users/users.ts
+++ b/frontend/src/api/generated/users/users.ts
@@ -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 unknown> = Parameters[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 => {
+ return customFetch(
+ getDeleteMeApiV1UsersMeDeleteUrl(),
+ {
+ ...options,
+ method: "DELETE",
+ },
+ );
+};
+
+export const getDeleteMeApiV1UsersMeDeleteMutationOptions = <
+ TError = unknown,
+ TContext = unknown,
+>(options?: {
+ mutation?: UseMutationOptions<
+ Awaited>,
+ TError,
+ void,
+ TContext
+ >;
+ request?: SecondParameter;
+}): UseMutationOptions<
+ Awaited>,
+ 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>,
+ void
+ > = () => {
+ return deleteMeApiV1UsersMeDelete(requestOptions);
+ };
+
+ return { mutationFn, ...mutationOptions };
+};
+
+export type DeleteMeApiV1UsersMeDeleteMutationResult = NonNullable<
+ Awaited>
+>;
+
+export type DeleteMeApiV1UsersMeDeleteMutationError = unknown;
+
+/**
+ * @summary Delete Me
+ */
+export const useDeleteMeApiV1UsersMeDelete = <
+ TError = unknown,
+ TContext = unknown,
+>(
+ options?: {
+ mutation?: UseMutationOptions<
+ Awaited>,
+ TError,
+ void,
+ TContext
+ >;
+ request?: SecondParameter;
+ },
+ queryClient?: QueryClient,
+): UseMutationResult<
+ Awaited>,
+ TError,
+ void,
+ TContext
+> => {
+ return useMutation(
+ getDeleteMeApiV1UsersMeDeleteMutationOptions(options),
+ queryClient,
+ );
+};
+/**
+ * Apply partial profile updates for the authenticated user.
* @summary Update Me
*/
export type updateMeApiV1UsersMePatchResponse200 = {
diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx
index 4d6de89..a2aaaf6 100644
--- a/frontend/src/app/dashboard/page.tsx
+++ b/frontend/src/app/dashboard/page.tsx
@@ -247,7 +247,7 @@ export default function DashboardPage() {
dashboardMetricsApiV1MetricsDashboardGetResponse,
ApiError
>(
- { range: "24h" },
+ { range_key: "24h" },
{
query: {
enabled: Boolean(isSignedIn),
diff --git a/frontend/src/app/gateways/[gatewayId]/page.tsx b/frontend/src/app/gateways/[gatewayId]/page.tsx
index e4cfea8..5d950dd 100644
--- a/frontend/src/app/gateways/[gatewayId]/page.tsx
+++ b/frontend/src/app/gateways/[gatewayId]/page.tsx
@@ -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,
},
});
diff --git a/frontend/src/app/onboarding/page.tsx b/frontend/src/app/onboarding/page.tsx
index 643b594..16230fd 100644
--- a/frontend/src/app/onboarding/page.tsx
+++ b/frontend/src/app/onboarding/page.tsx
@@ -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 })),
diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx
new file mode 100644
index 0000000..4cf6519
--- /dev/null
+++ b/frontend/src/app/settings/page.tsx
@@ -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;
+};
+
+export default function SettingsPage() {
+ const router = useRouter();
+ const queryClient = useQueryClient();
+ const { isSignedIn } = useAuth();
+ const { user } = useUser();
+
+ const [name, setName] = useState("");
+ const [timezone, setTimezone] = useState(null);
+ const [nameEdited, setNameEdited] = useState(false);
+ const [timezoneEdited, setTimezoneEdited] = useState(false);
+ const [saveError, setSaveError] = useState(null);
+ const [saveSuccess, setSaveSuccess] = useState(null);
+ const [deleteError, setDeleteError] = useState(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({
+ 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({
+ 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) => {
+ 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 (
+ <>
+
+
+
+ Profile
+
+ Keep your identity and timezone up to date.
+
+
+
+
+
+
+
+ Delete account
+
+
+ This permanently removes your Mission Control account and related
+ personal data. This action cannot be undone.
+
+
+
+
+
+
+
+
+ deleteAccountMutation.mutate()}
+ isConfirming={deleteAccountMutation.isPending}
+ errorMessage={deleteError}
+ confirmLabel="Delete account"
+ confirmingLabel="Deleting account…"
+ ariaLabel="Delete account confirmation"
+ />
+ >
+ );
+}
diff --git a/frontend/src/components/organisms/UserMenu.tsx b/frontend/src/components/organisms/UserMenu.tsx
index 16459f3..c074e2f 100644
--- a/frontend/src/components/organisms/UserMenu.tsx
+++ b/frontend/src/components/organisms/UserMenu.tsx
@@ -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 (
@@ -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) => (
{
if (!isSignedIn || isOnboardingPath) return;
@@ -91,7 +90,7 @@ export function DashboardShell({ children }: { children: ReactNode }) {
Operator
-
+
diff --git a/frontend/src/lib/timezones.ts b/frontend/src/lib/timezones.ts
new file mode 100644
index 0000000..6055125
--- /dev/null
+++ b/frontend/src/lib/timezones.ts
@@ -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;
+}