diff --git a/backend/app/api/users.py b/backend/app/api/users.py index 2ab8b22e..83371441 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 2bb22e83..36f0dcfb 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 00000000..c6d32d86 --- /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 abf3af8a..cf2e0a01 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 4d6de89c..a2aaaf63 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 e4cfea88..5d950dd4 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 643b5949..16230fd6 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 00000000..4cf65192 --- /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. +

+ +
+
+
+ + { + setName(event.target.value); + setNameEdited(true); + }} + placeholder="Your name" + disabled={isSaving} + className="border-slate-300 text-slate-900 focus-visible:ring-blue-500" + /> +
+
+ + { + 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" + /> +
+
+ +
+ + +
+ + {saveError ? ( +
+ {saveError} +
+ ) : null} + {saveSuccess ? ( +
+ {saveSuccess} +
+ ) : null} + +
+ + +
+
+
+ +
+

+ 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 16459f3d..c074e2fe 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 00000000..6055125a --- /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; +}