2026-06-25 19:25:00 +07:00
|
|
|
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";
|
2026-06-29 05:55:59 +07:00
|
|
|
import { toIso } from "./mappers";
|
2026-06-25 19:25:00 +07:00
|
|
|
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,
|
2026-06-30 20:45:16 +07:00
|
|
|
sharedConnectionsHidden: Boolean(u.hasSharedConnectionsOptOut),
|
|
|
|
|
discordFriendsHidden: Boolean(u.hasDiscordFriendsOptOut),
|
2026-06-25 19:25:00 +07:00
|
|
|
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<CurrentUser> {
|
|
|
|
|
const { data } = await vrc.getCurrentUser({ throwOnError: true });
|
|
|
|
|
if ("requiresTwoFactorAuth" in data) throw { status: 401, message: "Session expired" };
|
|
|
|
|
return data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function getSettings(): Promise<AccountSettings> {
|
|
|
|
|
return toSettings(await fetchCurrentUser(requireActiveClient()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type UpdateBody = Parameters<VRChat["updateUser"]>[0]["body"];
|
|
|
|
|
|
|
|
|
|
async function patch(body: UpdateBody): Promise<AccountSettings> {
|
|
|
|
|
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<AccountSettings> {
|
|
|
|
|
return patch({ displayName, currentPassword });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function revertDisplayName(currentPassword: string): Promise<AccountSettings> {
|
|
|
|
|
return patch({ revertDisplayName: true, currentPassword });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function setEmail(email: string, currentPassword: string): Promise<AccountSettings> {
|
|
|
|
|
return patch({ email, currentPassword });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function setPassword(
|
|
|
|
|
currentPassword: string,
|
|
|
|
|
newPassword: string,
|
|
|
|
|
): Promise<AccountSettings> {
|
|
|
|
|
return patch({ currentPassword, password: newPassword });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function setPrivacy(p: {
|
|
|
|
|
sharedConnectionsHidden?: boolean;
|
|
|
|
|
discordFriendsHidden?: boolean;
|
|
|
|
|
}): Promise<AccountSettings> {
|
2026-06-30 20:45:16 +07:00
|
|
|
const body: UpdateBody = {};
|
2026-06-25 19:25:00 +07:00
|
|
|
if (p.sharedConnectionsHidden !== undefined)
|
|
|
|
|
body.hasSharedConnectionsOptOut = p.sharedConnectionsHidden;
|
|
|
|
|
if (p.discordFriendsHidden !== undefined) body.hasDiscordFriendsOptOut = p.discordFriendsHidden;
|
2026-06-30 20:45:16 +07:00
|
|
|
return patch(body);
|
2026-06-25 19:25:00 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function setPresence(status: UserStatus, statusDescription: string): Promise<void> {
|
|
|
|
|
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<AccountSettings> {
|
|
|
|
|
const ordered = CONTENT_FILTER_KEYS.filter((k) => filters.includes(k));
|
|
|
|
|
return patch({ contentFilters: ordered });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function beginTwoFactorSetup(): Promise<Pending2Fa> {
|
|
|
|
|
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<AccountSettings> {
|
|
|
|
|
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<RecoveryCode[]> {
|
|
|
|
|
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<void> {
|
|
|
|
|
const vrc = requireActiveClient();
|
|
|
|
|
const me = await fetchCurrentUser(vrc);
|
|
|
|
|
await vrc.deleteAllUserPersistenceData({ path: { userId: me.id }, throwOnError: true });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function deleteAccount(): Promise<AccountSettings> {
|
|
|
|
|
const vrc = requireActiveClient();
|
|
|
|
|
const me = await fetchCurrentUser(vrc);
|
|
|
|
|
const { data } = await vrc.deleteUser({ path: { userId: me.id }, throwOnError: true });
|
|
|
|
|
invalidateSelf();
|
|
|
|
|
return toSettings(data);
|
|
|
|
|
}
|