refactor: implement user deletion functionality and enhance settings management
This commit is contained in:
@@ -3,23 +3,191 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
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.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
|
from app.schemas.users import UserRead, UserUpdate
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from app.models.users import User
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["users"])
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
AUTH_CONTEXT_DEP = Depends(get_auth_context)
|
AUTH_CONTEXT_DEP = Depends(get_auth_context)
|
||||||
SESSION_DEP = Depends(get_session)
|
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)
|
@router.get("/me", response_model=UserRead)
|
||||||
async def get_me(auth: AuthContext = AUTH_CONTEXT_DEP) -> UserRead:
|
async def get_me(auth: AuthContext = AUTH_CONTEXT_DEP) -> UserRead:
|
||||||
"""Return the authenticated user's current profile payload."""
|
"""Return the authenticated user's current profile payload."""
|
||||||
@@ -45,3 +213,71 @@ async def update_me(
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(user)
|
await session.refresh(user)
|
||||||
return UserRead.model_validate(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()
|
||||||
|
|||||||
@@ -332,6 +332,67 @@ async def _fetch_clerk_profile(clerk_user_id: str) -> tuple[str | None, str | No
|
|||||||
return None, None
|
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(
|
async def _get_or_sync_user(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
*,
|
*,
|
||||||
|
|||||||
98
backend/tests/test_users_delete_api.py
Normal file
98
backend/tests/test_users_delete_api.py
Normal 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
|
||||||
@@ -20,13 +20,19 @@ import type {
|
|||||||
UseQueryResult,
|
UseQueryResult,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
|
|
||||||
import type { HTTPValidationError, UserRead, UserUpdate } from ".././model";
|
import type {
|
||||||
|
HTTPValidationError,
|
||||||
|
OkResponse,
|
||||||
|
UserRead,
|
||||||
|
UserUpdate,
|
||||||
|
} from ".././model";
|
||||||
|
|
||||||
import { customFetch } from "../../mutator";
|
import { customFetch } from "../../mutator";
|
||||||
|
|
||||||
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Return the authenticated user's current profile payload.
|
||||||
* @summary Get Me
|
* @summary Get Me
|
||||||
*/
|
*/
|
||||||
export type getMeApiV1UsersMeGetResponse200 = {
|
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
|
* @summary Update Me
|
||||||
*/
|
*/
|
||||||
export type updateMeApiV1UsersMePatchResponse200 = {
|
export type updateMeApiV1UsersMePatchResponse200 = {
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ export default function DashboardPage() {
|
|||||||
dashboardMetricsApiV1MetricsDashboardGetResponse,
|
dashboardMetricsApiV1MetricsDashboardGetResponse,
|
||||||
ApiError
|
ApiError
|
||||||
>(
|
>(
|
||||||
{ range: "24h" },
|
{ range_key: "24h" },
|
||||||
{
|
{
|
||||||
query: {
|
query: {
|
||||||
enabled: Boolean(isSignedIn),
|
enabled: Boolean(isSignedIn),
|
||||||
|
|||||||
@@ -69,14 +69,14 @@ export default function GatewayDetailPage() {
|
|||||||
gateway_token: gateway.token ?? undefined,
|
gateway_token: gateway.token ?? undefined,
|
||||||
gateway_main_session_key: gateway.main_session_key ?? undefined,
|
gateway_main_session_key: gateway.main_session_key ?? undefined,
|
||||||
}
|
}
|
||||||
: undefined;
|
: {};
|
||||||
|
|
||||||
const statusQuery = useGatewaysStatusApiV1GatewaysStatusGet<
|
const statusQuery = useGatewaysStatusApiV1GatewaysStatusGet<
|
||||||
gatewaysStatusApiV1GatewaysStatusGetResponse,
|
gatewaysStatusApiV1GatewaysStatusGetResponse,
|
||||||
ApiError
|
ApiError
|
||||||
>(statusParams, {
|
>(statusParams, {
|
||||||
query: {
|
query: {
|
||||||
enabled: Boolean(isSignedIn && isAdmin && statusParams),
|
enabled: Boolean(isSignedIn && isAdmin && gateway),
|
||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import SearchableSelect from "@/components/ui/searchable-select";
|
import SearchableSelect from "@/components/ui/searchable-select";
|
||||||
import { isOnboardingComplete } from "@/lib/onboarding";
|
import { isOnboardingComplete } from "@/lib/onboarding";
|
||||||
|
import { getSupportedTimezones } from "@/lib/timezones";
|
||||||
|
|
||||||
export default function OnboardingPage() {
|
export default function OnboardingPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -76,30 +77,7 @@ export default function OnboardingPage() {
|
|||||||
[resolvedName, resolvedTimezone],
|
[resolvedName, resolvedTimezone],
|
||||||
);
|
);
|
||||||
|
|
||||||
const timezones = useMemo(() => {
|
const timezones = useMemo(() => getSupportedTimezones(), []);
|
||||||
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 timezoneOptions = useMemo(
|
const timezoneOptions = useMemo(
|
||||||
() => timezones.map((tz) => ({ value: tz, label: tz })),
|
() => timezones.map((tz) => ({ value: tz, label: tz })),
|
||||||
|
|||||||
284
frontend/src/app/settings/page.tsx
Normal file
284
frontend/src/app/settings/page.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
Plus,
|
Plus,
|
||||||
Server,
|
Server,
|
||||||
|
Settings,
|
||||||
Trello,
|
Trello,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -22,17 +23,26 @@ import {
|
|||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { cn } from "@/lib/utils";
|
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 [open, setOpen] = useState(false);
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
const avatarUrl = user.imageUrl ?? 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 avatarLabel = avatarLabelSource.slice(0, 1).toUpperCase();
|
||||||
const displayName =
|
const displayName = displayNameFromDb ?? "Account";
|
||||||
user.fullName ?? user.firstName ?? user.username ?? "Account";
|
const displayEmail = displayEmailFromDb ?? "";
|
||||||
const displayEmail = user.primaryEmailAddress?.emailAddress ?? "";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
@@ -140,6 +150,7 @@ export function UserMenu({ className }: { className?: string }) {
|
|||||||
{ href: "/activity", label: "Activity", icon: Activity },
|
{ href: "/activity", label: "Activity", icon: Activity },
|
||||||
{ href: "/agents", label: "Agents", icon: Bot },
|
{ href: "/agents", label: "Agents", icon: Bot },
|
||||||
{ href: "/gateways", label: "Gateways", icon: Server },
|
{ href: "/gateways", label: "Gateways", icon: Server },
|
||||||
|
{ href: "/settings", label: "Settings", icon: Settings },
|
||||||
] as const
|
] as const
|
||||||
).map((item) => (
|
).map((item) => (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useEffect } from "react";
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
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 { ApiError } from "@/api/mutator";
|
||||||
import {
|
import {
|
||||||
@@ -20,9 +20,6 @@ export function DashboardShell({ children }: { children: ReactNode }) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { isSignedIn } = useAuth();
|
const { isSignedIn } = useAuth();
|
||||||
const { user } = useUser();
|
|
||||||
const displayName =
|
|
||||||
user?.fullName ?? user?.firstName ?? user?.username ?? "Operator";
|
|
||||||
const isOnboardingPath = pathname === "/onboarding";
|
const isOnboardingPath = pathname === "/onboarding";
|
||||||
|
|
||||||
const meQuery = useGetMeApiV1UsersMeGet<
|
const meQuery = useGetMeApiV1UsersMeGet<
|
||||||
@@ -36,6 +33,8 @@ export function DashboardShell({ children }: { children: ReactNode }) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const profile = meQuery.data?.status === 200 ? meQuery.data.data : null;
|
const profile = meQuery.data?.status === 200 ? meQuery.data.data : null;
|
||||||
|
const displayName = profile?.name ?? profile?.preferred_name ?? "Operator";
|
||||||
|
const displayEmail = profile?.email ?? "";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSignedIn || isOnboardingPath) return;
|
if (!isSignedIn || isOnboardingPath) return;
|
||||||
@@ -91,7 +90,7 @@ export function DashboardShell({ children }: { children: ReactNode }) {
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-slate-500">Operator</p>
|
<p className="text-xs text-slate-500">Operator</p>
|
||||||
</div>
|
</div>
|
||||||
<UserMenu />
|
<UserMenu displayName={displayName} displayEmail={displayEmail} />
|
||||||
</div>
|
</div>
|
||||||
</SignedIn>
|
</SignedIn>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
26
frontend/src/lib/timezones.ts
Normal file
26
frontend/src/lib/timezones.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user