import type { CurrentUser, VRChat } from "vrchat"; import type { AccountSettings, ContentFilterKey, Pending2Fa, RecoveryCode, } from "../../shared/types/settings"; import type { TwoFactorMethod } from "../../shared/types/auth"; import type { UserStatus } from "../../shared/types/user"; import { requireActiveClient } from "./client"; import { toIso } from "./mappers"; import { userCache } from "./userService"; import { cacheKeys } from "../cache/policies"; import { entityStore } from "../store/entityStore"; const CONTENT_FILTER_KEYS: ContentFilterKey[] = [ "content_sex", "content_adult", "content_violence", "content_gore", "content_horror", ]; function toSettings(u: CurrentUser): AccountSettings { const filters = (u.contentFilters ?? []).filter((t): t is ContentFilterKey => (CONTENT_FILTER_KEYS as string[]).includes(t), ); const lastPast = [...(u.pastDisplayNames ?? [])].sort( (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(), )[0]; return { id: u.id, displayName: u.displayName, displayNameChangedAt: toIso(lastPast?.updated_at), previousDisplayName: lastPast?.displayName, supporter: (u.tags ?? []).includes("system_supporter"), pronouns: u.pronouns ?? "", email: u.obfuscatedEmail ?? "", emailVerified: u.emailVerified, pendingEmail: u.hasPendingEmail ? (u.obfuscatedPendingEmail ?? undefined) : undefined, twoFactorEnabled: u.twoFactorAuthEnabled, twoFactorEnabledDate: toIso(u.twoFactorAuthEnabledDate), usesGeneratedPassword: u.usesGeneratedPassword, ageVerificationStatus: u.ageVerificationStatus, ageVerified: u.ageVerified, isAdult: u.isAdult, contentFilters: filters, contentFiltersLocked: u.hideContentFilterSettings ?? false, sharedConnectionsHidden: Boolean(u.hasSharedConnectionsOptOut), discordFriendsHidden: Boolean(u.hasDiscordFriendsOptOut), discord: { linked: Boolean(u.discordId), label: u.discordDetails?.global_name }, google: { linked: Boolean(u.googleId) }, accountDeletionDate: toIso(u.accountDeletionDate) ?? null, }; } function invalidateSelf(): void { userCache.invalidate(cacheKeys.currentUser()); } function stepUpIfNeeded(data: unknown): void { if (!data || typeof data !== "object" || !("requiresTwoFactorAuth" in data)) return; const raw = (data as { requiresTwoFactorAuth?: string[] }).requiresTwoFactorAuth ?? []; const methods: TwoFactorMethod[] = []; if (raw.some((m) => m.toLowerCase() === "totp" || m.toLowerCase() === "otp")) methods.push("totp"); if (raw.some((m) => m.toLowerCase() === "emailotp")) methods.push("emailOtp"); throw { code: "requires_2fa", message: "Enter your two-factor code to continue.", methods: methods.length ? methods : (["totp"] as TwoFactorMethod[]), }; } export async function reverify2fa( method: TwoFactorMethod, code: string, ): Promise<{ verified: boolean }> { const vrc = requireActiveClient(); const { data } = method === "emailOtp" ? await vrc.verify2FaEmailCode({ body: { code }, throwOnError: true }) : await vrc.verify2Fa({ body: { code }, throwOnError: true }); return { verified: data.verified }; } async function fetchCurrentUser(vrc: VRChat): Promise { const { data } = await vrc.getCurrentUser({ throwOnError: true }); if ("requiresTwoFactorAuth" in data) throw { status: 401, message: "Session expired" }; return data; } export async function getSettings(): Promise { return toSettings(await fetchCurrentUser(requireActiveClient())); } type UpdateBody = Parameters[0]["body"]; async function patch(body: UpdateBody): Promise { const vrc = requireActiveClient(); const me = await fetchCurrentUser(vrc); const { data } = await vrc.updateUser({ path: { userId: me.id }, body, throwOnError: true }); invalidateSelf(); return toSettings(data); } export function setDisplayName( displayName: string, currentPassword: string, ): Promise { return patch({ displayName, currentPassword }); } export function revertDisplayName(currentPassword: string): Promise { return patch({ revertDisplayName: true, currentPassword }); } export function setEmail(email: string, currentPassword: string): Promise { return patch({ email, currentPassword }); } export function setPassword( currentPassword: string, newPassword: string, ): Promise { return patch({ currentPassword, password: newPassword }); } export function setPrivacy(p: { sharedConnectionsHidden?: boolean; discordFriendsHidden?: boolean; }): Promise { const body: UpdateBody = {}; if (p.sharedConnectionsHidden !== undefined) body.hasSharedConnectionsOptOut = p.sharedConnectionsHidden; if (p.discordFriendsHidden !== undefined) body.hasDiscordFriendsOptOut = p.discordFriendsHidden; return patch(body); } export async function setPresence(status: UserStatus, statusDescription: string): Promise { const vrc = requireActiveClient(); const me = await fetchCurrentUser(vrc); await vrc.updateUser({ path: { userId: me.id }, body: { status: status as never, statusDescription }, throwOnError: true, }); invalidateSelf(); entityStore.upsertFrom({ id: me.id, status, statusDescription }, "ws", Date.now()); } export function setContentFilters(filters: ContentFilterKey[]): Promise { const ordered = CONTENT_FILTER_KEYS.filter((k) => filters.includes(k)); return patch({ contentFilters: ordered }); } export async function beginTwoFactorSetup(): Promise { const vrc = requireActiveClient(); const { data } = await vrc.enable2Fa({ throwOnError: true }); return { secret: data.secret, qrCodeDataUrl: data.qrCodeDataUrl }; } export async function verifyTwoFactorSetup(code: string): Promise<{ verified: boolean }> { const vrc = requireActiveClient(); const { data } = await vrc.verifyPending2Fa({ body: { code }, throwOnError: true }); if (data.verified) invalidateSelf(); return { verified: data.verified }; } export async function disableTwoFactor(): Promise { const vrc = requireActiveClient(); const { data } = await vrc.disable2Fa({ throwOnError: true }); stepUpIfNeeded(data); if (!data.removed) throw { status: 400, message: "VRChat did not remove two-factor auth." }; invalidateSelf(); const settings = toSettings(await fetchCurrentUser(vrc)); return { ...settings, twoFactorEnabled: false, twoFactorEnabledDate: undefined }; } export async function getRecoveryCodes(): Promise { const vrc = requireActiveClient(); const { data } = await vrc.getRecoveryCodes({ throwOnError: true }); stepUpIfNeeded(data); return (data.otp ?? []).map((o) => ({ code: o.code, used: o.used })); } export async function resetUserData(): Promise { const vrc = requireActiveClient(); const me = await fetchCurrentUser(vrc); await vrc.deleteAllUserPersistenceData({ path: { userId: me.id }, throwOnError: true }); } export async function deleteAccount(): Promise { const vrc = requireActiveClient(); const me = await fetchCurrentUser(vrc); const { data } = await vrc.deleteUser({ path: { userId: me.id }, throwOnError: true }); invalidateSelf(); return toSettings(data); }