98e06cb
/**
* User-settings API: wrappers for every user-account endpoint (profile,
* security, sessions, devices, emails, preferences, access keys, ssh keys,
* suspensions). Workspace navigation data lives in the page slice's
* shell-api; space mutations in features/space-management.
*/
import { ApiFailure, settingsKey, unwrapApi } from '@app/lib'
import type { ChangePasswordCmd, UpdatePreferencesCmd, UpdateProfileCmd } from '@app/services/api'
import Apis, { trackFetch } from '@app/services/api'
import { invalidateCache, writeCache } from '@app/services/storage'
import type {
AvatarInfo,
CountResult,
DeviceRow,
RecoveryCodes,
SessionPage,
SshKey,
SshKeyPage,
SuspensionRow,
TotpSetup,
TotpStatus,
UserPreferences,
UserPublic,
} from './user-settings'
/* ---- account & profile ---------------------------------------------------- */
export const updateProfile = (cmd: UpdateProfileCmd) =>
unwrapApi<null>(Apis.account.update_profile({ data: cmd }))
export const updateUsername = (username: string) =>
unwrapApi<UserPublic>(Apis.account.update_username({ data: { username } }))
/** Binary upload — the generated client types the body as number[], so PUT
* the raw bytes with fetch and reuse the standard envelope contract. */
export async function uploadAvatar(file: File): Promise<AvatarInfo> {
const res = await trackFetch('/api/v1/me/avatar', {
method: 'PUT',
body: file,
headers: { 'content-type': file.type || 'application/octet-stream' },
})
const env = (await res.json()) as {
code: number
message: string
data?: AvatarInfo | null
error?: string | null
}
if (env.code !== 200) {
throw new ApiFailure(env.error || env.message || 'avatar upload failed', env.code)
}
return env.data as AvatarInfo
}
export const deleteAvatar = () => unwrapApi<null>(Apis.user.delete_avatar())
/* ---- password & 2FA ------------------------------------------------------- */
export const changePassword = (cmd: ChangePasswordCmd) => unwrapApi<null>(Apis.auth.change_password({ data: cmd }))
export const totpStatus = () => unwrapApi<TotpStatus>(Apis.auth.totp_status())
export const totpSetup = () => unwrapApi<TotpSetup>(Apis.auth.totp_setup())
/** Enabling 2FA hands back the one-time recovery codes. */
export const totpEnable = (code: string, password: string) =>
unwrapApi<RecoveryCodes>(Apis.auth.totp_enable({ data: { code, password } }))
export const totpDisable = (code: string) => unwrapApi<null>(Apis.auth.totp_disable({ data: { code } }))
export const recoveryCodes = () => unwrapApi<RecoveryCodes>(Apis.auth.recovery_codes())
/** Rotation requires the current password. */
export const regenerateRecoveryCodes = (password: string) =>
unwrapApi<RecoveryCodes>(Apis.auth.regenerate_recovery_codes({ data: { password } }))
/* ---- sessions & devices --------------------------------------------------- */
export const listSessions = () => unwrapApi<SessionPage>(Apis.user.list_sessions({ params: { limit: 25 } }))
export const revokeSession = (sessionId: string) =>
unwrapApi<null>(Apis.user.revoke_session({ pathParams: { session_id: sessionId } }))
export const revokeAllSessions = () => unwrapApi<CountResult>(Apis.user.revoke_all_sessions())
export const listDevices = () =>
unwrapApi<{ items?: DeviceRow[] }>(Apis.account.list_devices({ params: { limit: 100 } })).then(
(page) => page?.items ?? [],
)
/* ---- preferences ---------------------------------------------------------- */
/** Canonical home of the preference endpoints; features/space-management keeps
* its own four-line copies for the personal privacy dialog (features may not
* import each other). */
export const fetchPreferences = () => unwrapApi<UserPreferences>(Apis.account.get_preferences())
export const updatePreferences = (cmd: UpdatePreferencesCmd) =>
unwrapApi<UserPreferences>(Apis.account.update_preferences({ data: cmd })).then((prefs) => {
// The response is the canonical row: store it so every preferences
// consumer sees the change immediately, and drop the derived diff-style
// read the commit view caches under its own key.
writeCache(settingsKey('preferences'), prefs)
invalidateCache(settingsKey('diff-style'))
return prefs
})
/* ---- ssh public keys ------------------------------------------------------ */
export const listSshKeys = async (): Promise<SshKeyPage> =>
unwrapApi<SshKeyPage>(Apis.auth.auth_ssh_key_list({ params: { limit: 100 } }))
export const addSshKey = async (name: string, content: string) =>
unwrapApi<SshKey>(Apis.auth.auth_ssh_key_add({ data: { name, public_key: content } }))
export const deleteSshKey = (keyId: string) =>
unwrapApi<unknown>(Apis.auth.auth_ssh_key_delete({ pathParams: { key_id: keyId } }))
/* ---- account status ------------------------------------------------------- */
/** The endpoint answers with a bare array, not the paged envelope. The section
* is a punishment *history*, so revoked entries are included too. */
export const listSuspensions = async (): Promise<SuspensionRow[]> => {
const rows = await unwrapApi<SuspensionRow[] | null>(
Apis.user.my_suspensions({ params: { include_revoked: true } }),
)
return rows ?? []
}