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

@@ -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()

View File

@@ -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,
*,

View File

@@ -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

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;
}