diff --git a/frontend/src/html/pages/account-settings.html b/frontend/src/html/pages/account-settings.html
deleted file mode 100644
index f809e0eac9cd..000000000000
--- a/frontend/src/html/pages/account-settings.html
+++ /dev/null
@@ -1,285 +0,0 @@
-
diff --git a/frontend/src/styles/account-settings.scss b/frontend/src/styles/account-settings.scss
deleted file mode 100644
index ca4816e592e3..000000000000
--- a/frontend/src/styles/account-settings.scss
+++ /dev/null
@@ -1,195 +0,0 @@
-.pageAccountSettings {
- & > .content > .title {
- font-size: 2rem;
- color: var(--sub-color);
- display: inline-flex;
- align-items: baseline;
- margin-bottom: 1em;
- i {
- margin-right: 0.5em;
- }
- }
- .main {
- display: grid;
- grid-template-columns: 15rem 1fr;
- gap: 4rem;
- .tabs {
- background: var(--sub-alt-color);
- border-radius: calc(var(--roundness) * 2);
- padding: 1rem 0;
- display: flex;
- flex-direction: column;
- height: max-content;
- button {
- padding: 1em 2em;
- width: 100%;
- justify-content: flex-start;
- &.active {
- color: var(--text-color);
- }
- }
- }
- .right {
- padding-top: 1rem;
- .tab {
- display: grid;
- gap: 2rem;
-
- & > .title {
- color: var(--sub-color);
- font-size: 2rem;
- margin-bottom: 2rem;
- }
-
- .section {
- display: grid;
- grid-template-areas: "title buttons" "text buttons";
- grid-template-columns: 2fr 1fr;
- grid-template-rows: auto 1fr;
- column-gap: 2rem;
- row-gap: 0.5rem;
- align-items: center;
- .title {
- color: var(--sub-color);
- grid-area: title;
- }
- .text {
- grid-area: text;
- align-self: normal;
- .red {
- color: var(--error-color);
- }
- }
- .buttons {
- grid-area: buttons;
- display: grid;
- &.vertical {
- grid-auto-flow: row;
- gap: 0.5rem;
- }
- }
- &.discordIntegration {
- .info {
- grid-area: buttons;
- text-align: center;
- color: var(--main-color);
- }
-
- #discordButtonGroup {
- display: grid;
- grid-auto-flow: column;
- justify-content: center;
- gap: 0.5rem;
- }
-
- #unlinkDiscordButton,
- #updateDiscordAvatarButton {
- margin: 0.5rem auto 0 auto;
- font-size: 0.75rem;
- line-height: 0.7rem;
- }
-
- .howto {
- margin-top: 1rem;
- color: var(--text-color);
- }
- }
- &.optOutOfLeaderboards {
- .optedOut {
- grid-area: buttons;
- text-align: center;
- color: var(--sub-color);
- }
- }
- &.setStreakHourOffset {
- .info {
- grid-area: buttons;
- text-align: center;
- color: var(--sub-color);
- }
- }
- &.apeKeys {
- .lostAccess {
- grid-area: buttons;
- text-align: center;
- color: var(--error-color);
- }
- }
- }
- &[data-tab="apeKeys"] {
- table {
- width: 100%;
- border-spacing: 0;
- border-collapse: collapse;
-
- tr.me {
- td {
- color: var(--main-color);
- // font-weight: 900;
- }
- }
-
- td {
- padding: 0.5rem 0.5rem;
- }
-
- thead {
- color: var(--sub-color);
- font-size: 0.75rem;
-
- td {
- padding: 0.5rem;
- background: var(--bg-color);
- position: sticky;
- top: 0;
- z-index: 99;
- }
- }
-
- tbody {
- color: var(--text-color);
-
- tr:nth-child(odd) td {
- background: var(--sub-alt-color);
- }
- }
-
- tfoot {
- td {
- padding: 1rem 0.5rem;
- position: sticky;
- bottom: -5px;
- background: var(--bg-color);
- color: var(--main-color);
- z-index: 4;
- }
- }
-
- tr {
- td:first-child {
- padding-left: 1rem;
- }
- td:last-child {
- padding-right: 1rem;
- }
- }
- .keyButtons {
- display: flex;
- gap: 0.5rem;
- }
- }
- }
-
- &[data-tab="apeKeys"] {
- tr td:first-child {
- text-align: center;
- }
- }
- }
- }
- // .right {
- // background: var(--sub-alt-color);
- // border-radius: calc(var(--roundness) * 2);
- // }
- }
-}
diff --git a/frontend/src/styles/index.scss b/frontend/src/styles/index.scss
index 1b1dc6632a16..5b53a661e173 100644
--- a/frontend/src/styles/index.scss
+++ b/frontend/src/styles/index.scss
@@ -18,7 +18,7 @@
@layer custom-styles {
@import "buttons", "ads", "test-activity", "animations", "caret",
"commandline", "core", "fonts", "inputs", "keymap", "monkey", "popups",
- "scroll", "account-settings", "test", "loading", "media-queries";
+ "scroll", "test", "loading", "media-queries";
.chartCanvas {
width: 100% !important;
diff --git a/frontend/src/ts/auth.tsx b/frontend/src/ts/auth.tsx
index 52b7231bd230..5647b94bec9a 100644
--- a/frontend/src/ts/auth.tsx
+++ b/frontend/src/ts/auth.tsx
@@ -6,14 +6,17 @@ import {
EmailAuthProvider,
GithubAuthProvider,
GoogleAuthProvider,
+ linkWithCredential,
linkWithPopup,
reauthenticateWithCredential,
reauthenticateWithPopup,
unlink,
+ updateEmail,
updateProfile,
User,
User as UserType,
} from "firebase/auth";
+import { createMemo } from "solid-js";
import { z, ZodString } from "zod";
import Ape from "./ape";
@@ -31,14 +34,17 @@ import {
signInWithEmailAndPassword,
signInWithPopup,
} from "./firebase";
+import { createSignalWithSetters } from "./hooks/createSignalWithSetters";
+import { createEffectOn } from "./hooks/effects";
import * as Sentry from "./sentry";
-import { isAuthenticated, setUserId } from "./states/core";
+import { getUserId, isAuthenticated, setUserId } from "./states/core";
import { hideLoaderBar, showLoaderBar } from "./states/loader-bar";
import {
showErrorNotification,
showNoticeNotification,
showSuccessNotification,
} from "./states/notifications";
+import { FaObject } from "./types/font-awesome";
import { isDevEnvironment } from "./utils/env";
import { createErrorMessage } from "./utils/error";
import { typedKeys } from "./utils/misc";
@@ -47,6 +53,7 @@ import { OneOf } from "./utils/types";
type AuthMethodInfo = {
display: string;
+ fa: FaObject;
} & OneOf<{
provider: AuthProvider;
providerId: string;
@@ -60,18 +67,22 @@ const authMethods = {
password: {
display: "Password",
providerId: "password",
+ fa: { icon: "fa-lock" },
},
github: {
display: "GitHub",
provider: new GithubAuthProvider(),
+ fa: { variant: "brand", icon: "fa-github" },
},
google: {
display: "Google",
provider: new GoogleAuthProvider(),
+ fa: { variant: "brand", icon: "fa-google" },
},
} as const satisfies Record
;
export type AuthMethod = keyof typeof authMethods;
+export type ProviderAuthMethod = Exclude;
export type AuthResult =
| {
@@ -98,6 +109,41 @@ type ReauthenticateOptions = {
password?: string;
};
+const [getAuthenticatedUserReactive, { updateAuthenticatedUser }] =
+ createSignalWithSetters | null>(null)({
+ updateAuthenticatedUser: (set) => {
+ const user = getAuthenticatedUser();
+ if (user === null) {
+ set(null);
+ } else {
+ set({ providerData: user.providerData });
+ }
+ },
+ });
+export { getAuthenticatedUser };
+
+createEffectOn(getUserId, () => {
+ updateAuthenticatedUser();
+});
+
+const authenticationMemos = Object.fromEntries(
+ typedKeys(authMethods).map((authMethod) => {
+ const memo = createMemo(() => {
+ const providerId = getProviderId(authMethod);
+
+ const user = getAuthenticatedUserReactive();
+ if (user === null) return undefined;
+ const result = {
+ isInUse: user.providerData.some((p) => p.providerId === providerId),
+ hasAdditionalAuthMethods: hasAdditionalAuthMethods(authMethod),
+ };
+
+ return result;
+ });
+ return [authMethod, memo];
+ }),
+);
+
export async function sendVerificationEmail(): Promise {
if (!isAuthAvailable()) {
showErrorNotification("Authentication uninitialized", { durationMs: 3000 });
@@ -241,34 +287,88 @@ export async function signInWithProvider(
return { success: true };
}
-export async function addAuthProvider(authMethod: AuthMethod): Promise {
+export async function addAuthProvider(
+ options:
+ | { authMethod: ProviderAuthMethod }
+ | {
+ authMethod: "password";
+ email: string;
+ password: string;
+ },
+): Promise {
if (!isAuthAvailable()) {
showErrorNotification("Authentication uninitialized", { durationMs: 3000 });
return;
}
- const provider = getAuthProvider(authMethod);
- if (provider === undefined) {
- showErrorNotification(`Authentication ${authMethod} is missing a provider`);
- return;
- }
- const providerName = getAuthMethodDisplay(authMethod);
+ const authMethod = options.authMethod;
- showLoaderBar();
const user = getAuthenticatedUser();
+ const providerName = getAuthMethodDisplay(authMethod);
+
if (!user) return;
+ showLoaderBar();
try {
- await linkWithPopup(user, provider);
- hideLoaderBar();
+ if (authMethod === "password") {
+ await addPasswordProvider(user, options);
+ } else {
+ await addPopupProvider(user, options);
+ }
+
showSuccessNotification(`${providerName} authentication added`);
- authEvent.dispatch({ type: "authConfigUpdated" });
+ updateAuthenticatedUser();
} catch (error) {
- hideLoaderBar();
showErrorNotification(`Failed to add ${providerName} authentication`, {
error,
});
+ } finally {
+ hideLoaderBar();
+ }
+}
+
+async function addPasswordProvider(
+ user: User,
+ options: {
+ email: string;
+ password: string;
+ },
+) {
+ const reauth = await reauthenticate({ password: options.password });
+ if (reauth.status !== "success") {
+ throw new Error(reauth.message);
+ }
+ const credential = EmailAuthProvider.credential(
+ options.email,
+ options.password,
+ );
+ await linkWithCredential(reauth.user, credential);
+ await updateEmail(user, options.email);
+ const response = await Ape.users.updateEmail({
+ body: {
+ newEmail: options.email,
+ previousEmail: reauth.user.email as string,
+ },
+ });
+ if (response.status !== 200) {
+ throw new Error(
+ "Password authentication added but updating the database email failed. This shouldn't happen, please contact support. Error",
+ );
}
}
+async function addPopupProvider(
+ user: User,
+ options: { authMethod: ProviderAuthMethod },
+) {
+ const authMethod = options.authMethod;
+ const provider = getAuthProvider(authMethod);
+ if (provider === undefined) {
+ throw new Error(`Authentication ${authMethod} is missing a provider`);
+ }
+
+ await linkWithPopup(user, provider);
+ authEvent.dispatch({ type: "authConfigUpdated" });
+}
+
export async function removeAuthProvider(
authMethod: AuthMethod,
options?: { password?: string },
@@ -285,6 +385,7 @@ export async function removeAuthProvider(
}
try {
await unlink(reauth.user, getProviderId(authMethod));
+ updateAuthenticatedUser();
} catch (e) {
const message = createErrorMessage(
e,
@@ -464,7 +565,7 @@ function getPreferredAuthenticationMethod(
return undefined;
}
-function isUsingAuthentication(authMethod: AuthMethod): boolean {
+export function isUsingAuthentication(authMethod: AuthMethod): boolean {
const providerId = getProviderId(authMethod);
return (
getAuthenticatedUser()?.providerData.some(
@@ -473,6 +574,10 @@ function isUsingAuthentication(authMethod: AuthMethod): boolean {
);
}
+export function isUsingAuthenticationReactive(authMethod: AuthMethod): boolean {
+ return authenticationMemos[authMethod]?.()?.isInUse ?? false;
+}
+
export function getPasswordSchema(): ZodString {
return isDevEnvironment() ? z.string().min(6) : PasswordSchema;
}
@@ -487,10 +592,18 @@ export function hasAdditionalAuthMethods(authMethod: AuthMethod) {
);
}
+export function hasAdditionalAuthMethodsReactive(authMethod: AuthMethod) {
+ return authenticationMemos[authMethod]?.()?.hasAdditionalAuthMethods ?? false;
+}
+
export function getAuthMethodDisplay(authMethod: AuthMethod): string {
return authMethods[authMethod].display;
}
+export function getAuthMethodIcon(authMethod: AuthMethod): FaObject {
+ return authMethods[authMethod].fa;
+}
+
function getProviderId(authMethod: AuthMethod): string {
const info = authMethods[authMethod];
diff --git a/frontend/src/ts/collections/ape-keys.ts b/frontend/src/ts/collections/ape-keys.ts
new file mode 100644
index 000000000000..262cf1809eb6
--- /dev/null
+++ b/frontend/src/ts/collections/ape-keys.ts
@@ -0,0 +1,200 @@
+import { queryCollectionOptions } from "@tanstack/query-db-collection";
+import {
+ createCollection,
+ createOptimisticAction,
+ useLiveQuery,
+} from "@tanstack/solid-db";
+import Ape from "../ape";
+import { queryClient } from "../queries";
+import { baseKey } from "../queries/utils/keys";
+import { isAuthenticated } from "../states/core";
+import {
+ setApeKeysDenied,
+ setLastGeneratedApeKey,
+} from "../states/account-settings";
+import { applyIdWorkaround, tempId } from "./utils/misc";
+import { typedEntries } from "../utils/misc";
+import { ApeKey } from "@monkeytype/schemas/ape-keys";
+import { showSuccessNotification } from "../states/notifications";
+import {
+ replaceSpacesWithUnderscores,
+ replaceUnderscoresWithSpaces,
+} from "../utils/strings";
+
+export type ApeKeyEntry = ApeKey & { _id: string };
+const queryKeys = {
+ root: () => [...baseKey("apeKeys", { isUserSpecific: true })],
+};
+
+// oxlint-disable-next-line typescript/explicit-function-return-type
+export function useApeKeyLiveQuery() {
+ return useLiveQuery((q) =>
+ isAuthenticated()
+ ? q
+ .from({ keys: apeKeysCollection })
+ .orderBy(({ keys }) => keys.createdOn, "asc")
+ : undefined,
+ );
+}
+
+const apeKeysCollection = createCollection(
+ queryCollectionOptions({
+ staleTime: Infinity,
+ queryKey: queryKeys.root(),
+ queryClient,
+ enabled: isAuthenticated,
+ getKey: (it) => it._id,
+ queryFn: async () => {
+ const response = await Ape.apeKeys.get();
+
+ if (response.status !== 200) {
+ if (
+ response.body.message ===
+ "You have lost access to ape keys, please contact support"
+ ) {
+ setApeKeysDenied(true);
+ }
+
+ throw new Error(`Error fetching ape keys: ${response.body.message}`);
+ }
+
+ const dataArray = typedEntries(response.body.data)
+ .map(
+ ([_id, data]) =>
+ ({
+ ...data,
+ _id,
+ name: replaceUnderscoresWithSpaces(data.name),
+ }) satisfies ApeKeyEntry,
+ )
+ .map(applyIdWorkaround);
+
+ return dataArray;
+ },
+ }),
+);
+
+type ActionType = {
+ insertKey: { name: string };
+ setEnabled: { apeKeyId: string; enabled: boolean };
+ rename: { apeKeyId: string; name: string };
+ remove: { apeKeyId: string };
+};
+
+const actions = {
+ insertKey: createOptimisticAction({
+ onMutate: ({ name }) => {
+ apeKeysCollection.insert({
+ _id: tempId(),
+ name: replaceSpacesWithUnderscores(name),
+ enabled: false,
+ lastUsedOn: -1,
+ createdOn: Date.now(),
+ modifiedOn: Date.now(),
+ });
+ },
+ mutationFn: async ({ name }) => {
+ const response = await Ape.apeKeys.add({
+ body: { name, enabled: false },
+ });
+
+ if (response.status !== 200) {
+ throw new Error(`Failed to add key: ${response.body.message}`);
+ }
+
+ const newKey = {
+ ...response.body.data.apeKeyDetails,
+ _id: response.body.data.apeKeyId,
+ name: replaceUnderscoresWithSpaces(name),
+ };
+
+ apeKeysCollection.utils.writeInsert(newKey);
+
+ setLastGeneratedApeKey(response.body.data.apeKey);
+ },
+ }),
+ setEnabled: createOptimisticAction({
+ onMutate: ({ apeKeyId, enabled }) => {
+ apeKeysCollection.update(apeKeyId, (key) => {
+ key.enabled = enabled;
+ });
+ },
+ mutationFn: async ({ apeKeyId, enabled }) => {
+ const response = await Ape.apeKeys.save({
+ params: { apeKeyId },
+ body: { enabled },
+ });
+
+ if (response.status !== 200) {
+ throw new Error(`Failed to update key: ${response.body.message}`);
+ }
+
+ apeKeysCollection.utils.writeUpdate({ _id: apeKeyId, enabled });
+
+ showSuccessNotification(`Key ${enabled ? "active" : "inactive"}`);
+ },
+ }),
+ rename: createOptimisticAction({
+ onMutate: ({ apeKeyId, name }) => {
+ apeKeysCollection.update(apeKeyId, (key) => {
+ key.name = replaceUnderscoresWithSpaces(name);
+ });
+ },
+ mutationFn: async ({ apeKeyId, name }) => {
+ const response = await Ape.apeKeys.save({
+ params: { apeKeyId },
+ body: { name },
+ });
+
+ if (response.status !== 200) {
+ throw new Error(`Failed to update key: ${response.body.message}`);
+ }
+
+ apeKeysCollection.utils.writeUpdate({
+ _id: apeKeyId,
+ name: replaceUnderscoresWithSpaces(name),
+ });
+ },
+ }),
+ remove: createOptimisticAction({
+ onMutate: ({ apeKeyId }) => {
+ apeKeysCollection.delete(apeKeyId);
+ },
+ mutationFn: async ({ apeKeyId }) => {
+ const response = await Ape.apeKeys.delete({ params: { apeKeyId } });
+ if (response.status !== 200) {
+ throw new Error(`Failed to delete key: ${response.body.message}`);
+ }
+
+ apeKeysCollection.utils.writeDelete(apeKeyId);
+ },
+ }),
+};
+
+export async function insertApeKey(
+ params: ActionType["insertKey"],
+): Promise {
+ const transaction = actions.insertKey(params);
+ await transaction.isPersisted.promise;
+}
+
+export async function updateApeKeyEnabled(
+ params: ActionType["setEnabled"],
+): Promise {
+ const transaction = actions.setEnabled(params);
+ await transaction.isPersisted.promise;
+}
+
+export async function renameApeKey(
+ params: ActionType["rename"],
+): Promise {
+ const transaction = actions.rename(params);
+ await transaction.isPersisted.promise;
+}
+
+export async function removeApeKey(
+ params: ActionType["remove"],
+): Promise {
+ const transaction = actions.remove(params);
+ await transaction.isPersisted.promise;
+}
diff --git a/frontend/src/ts/components/common/Setting.tsx b/frontend/src/ts/components/common/Setting.tsx
new file mode 100644
index 000000000000..c01dbb25f508
--- /dev/null
+++ b/frontend/src/ts/components/common/Setting.tsx
@@ -0,0 +1,132 @@
+import { JSXElement, ParentProps, Show } from "solid-js";
+import { z } from "zod";
+import { serialize } from "zod-urlsearchparams";
+
+import {
+ showErrorNotification,
+ showSuccessNotification,
+} from "../../states/notifications";
+import { cn } from "../../utils/cn";
+import { Button } from "./Button";
+import { FaProps } from "./Fa";
+import { H3 } from "./Headers";
+
+export type SettingProps = {
+ title: string;
+ fa: FaProps;
+ description: string | JSXElement;
+ inputs?: JSXElement;
+ fullWidthInputs?: JSXElement;
+ breakpoints?: "none" | "normal" | "narrow";
+} & ParentProps &
+ (
+ | {
+ /**
+ * data-settings-key
+ */
+ key: string;
+ showDeepLink?: true;
+ }
+ | {
+ key?: never;
+ showDeepLink: false;
+ }
+ ) &
+ (
+ | {
+ disabled: boolean;
+ disabledDescription: JSXElement;
+ }
+ | {
+ disabled?: never;
+ disabledDescription?: never;
+ }
+ );
+
+export function Setting(props: SettingProps): JSXElement {
+ const breakpoints = () => props.breakpoints ?? "normal";
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {props.description}
+
+
+ {props.inputs}
+
+
+ {props.children}
+
+
+
+ {props.fullWidthInputs}
+
+
+ );
+}
+
+function DeepLinkButton(props: { key: string }) {
+ return (
+ {
+ const urlParams = serialize({
+ schema: z.object({
+ highlight: z.string(),
+ }),
+ data: {
+ highlight: props.key,
+ },
+ });
+ const newUrl = `${window.location.pathname}?${urlParams.toString()}`;
+ window.history.replaceState({}, "", newUrl);
+
+ navigator.clipboard
+ .writeText(window.location.toString())
+ .then(() => {
+ showSuccessNotification("Link copied to clipboard");
+ })
+ .catch((e: unknown) => {
+ showErrorNotification("Failed to copy to clipboard", {
+ error: e,
+ });
+ });
+ }}
+ />
+ );
+}
diff --git a/frontend/src/ts/components/modals/account-settings/AddPasswordAuthModal.tsx b/frontend/src/ts/components/modals/account-settings/AddPasswordAuthModal.tsx
index a9f4593392b5..f30813912285 100644
--- a/frontend/src/ts/components/modals/account-settings/AddPasswordAuthModal.tsx
+++ b/frontend/src/ts/components/modals/account-settings/AddPasswordAuthModal.tsx
@@ -1,15 +1,10 @@
import { UserEmailSchema } from "@monkeytype/schemas/users";
-import { EmailAuthProvider, linkWithCredential } from "firebase/auth";
import { z } from "zod";
-import Ape from "../../../ape";
-import { getPasswordSchema, reauthenticate } from "../../../auth";
+import { addAuthProvider, getPasswordSchema } from "../../../auth";
import { showSimpleModal } from "../../../states/simple-modal";
-import { createErrorMessage } from "../../../utils/error";
-export function showAddPasswordAuthModal(options: {
- callback: () => void;
-}): void {
+export function showAddPasswordAuthModal(): void {
showSimpleModal({
title: "Add password authentication",
buttonText: "reauthenticate to add",
@@ -53,48 +48,11 @@ export function showAddPasswordAuthModal(options: {
};
}
- const reauth = await reauthenticate({ password });
- if (reauth.status !== "success") {
- return {
- status: reauth.status,
- message: reauth.message,
- };
- }
-
- try {
- const credential = EmailAuthProvider.credential(email, password);
- await linkWithCredential(reauth.user, credential);
- } catch (e) {
- const message = createErrorMessage(
- e,
- "Failed to add password authentication",
- );
- return {
- status: "error",
- message,
- };
- }
-
- const response = await Ape.users.updateEmail({
- body: {
- newEmail: email,
- previousEmail: reauth.user.email as string,
- },
- });
- if (response.status !== 200) {
- return {
- status: "error",
- message:
- "Password authentication added but updating the database email failed. This shouldn't happen, please contact support. Error",
- notificationOptions: { response },
- };
- }
-
- options.callback();
+ await addAuthProvider({ authMethod: "password", email, password });
return {
status: "success",
- message: "Password authentication added",
+ showNotification: false,
};
},
});
diff --git a/frontend/src/ts/components/modals/account-settings/RemoveAuthMethodModal.tsx b/frontend/src/ts/components/modals/account-settings/RemoveAuthMethodModal.tsx
index 0485f003052f..e6782cc44b81 100644
--- a/frontend/src/ts/components/modals/account-settings/RemoveAuthMethodModal.tsx
+++ b/frontend/src/ts/components/modals/account-settings/RemoveAuthMethodModal.tsx
@@ -11,11 +11,9 @@ import {
import { isAuthenticated } from "../../../states/core";
import { showNoticeNotification } from "../../../states/notifications";
import { showSimpleModal } from "../../../states/simple-modal";
-import { reloadAfter } from "../../../utils/misc";
export function showRemoveAuthMethodModal(options: {
authMethod: AuthMethod;
- callback: () => void;
}): void {
if (!isAuthenticated()) return;
@@ -58,9 +56,6 @@ export function showRemoveAuthMethodModal(options: {
return result;
}
- options.callback();
-
- reloadAfter(3);
return result;
},
});
diff --git a/frontend/src/ts/components/modals/account-settings/UnlinkDiscordModal.tsx b/frontend/src/ts/components/modals/account-settings/UnlinkDiscordModal.tsx
index 6e8604cec8a5..879087b1bc9a 100644
--- a/frontend/src/ts/components/modals/account-settings/UnlinkDiscordModal.tsx
+++ b/frontend/src/ts/components/modals/account-settings/UnlinkDiscordModal.tsx
@@ -2,9 +2,7 @@ import Ape from "../../../ape";
import { getSnapshot, setSnapshot } from "../../../db";
import { showSimpleModal } from "../../../states/simple-modal";
-export function showUnlinkDiscordModal(options: {
- callback: () => void;
-}): void {
+export function showUnlinkDiscordModal(): void {
showSimpleModal({
title: "Unlink Discord",
text: "Are you sure you want to unlink your Discord account?",
@@ -31,8 +29,6 @@ export function showUnlinkDiscordModal(options: {
snap.discordId = undefined;
setSnapshot(snap);
- options.callback();
-
return {
status: "success",
message: "Discord unlinked",
diff --git a/frontend/src/ts/components/modals/account-settings/UpdateNameModal.tsx b/frontend/src/ts/components/modals/account-settings/UpdateNameModal.tsx
index 9e503fb12473..48c664ad22ae 100644
--- a/frontend/src/ts/components/modals/account-settings/UpdateNameModal.tsx
+++ b/frontend/src/ts/components/modals/account-settings/UpdateNameModal.tsx
@@ -7,14 +7,14 @@ import {
isUsingPasswordAuthentication,
reauthenticate,
} from "../../../auth";
-import * as DB from "../../../db";
+import { setSnapshot } from "../../../db";
import { isAuthenticated } from "../../../states/core";
import { showSimpleModal } from "../../../states/simple-modal";
-import { reloadAfter } from "../../../utils/misc";
+import { getSnapshot } from "../../../states/snapshot";
import { remoteValidation } from "../../../utils/remote-validation";
export function showUpdateNameModal(): void {
- const snapshot = DB.getSnapshot();
+ const snapshot = getSnapshot();
if (!isAuthenticated() || !snapshot) return;
showSimpleModal({
@@ -22,7 +22,7 @@ export function showUpdateNameModal(): void {
buttonText: isUsingPasswordAuthentication()
? "update"
: "reauthenticate to update",
- text: DB.getSnapshot()?.needsToChangeName
+ text: snapshot.needsToChangeName
? "You need to change your account name. This might be because you have a duplicate name, no account name or your name is not allowed (contains whitespace or invalid characters). Sorry for the inconvenience."
: undefined,
schema: z.object({
@@ -70,10 +70,8 @@ export function showUpdateNameModal(): void {
}
snapshot.name = newName;
- DB.setSnapshot(snapshot);
- if (snapshot.needsToChangeName) {
- reloadAfter(2);
- }
+
+ setSnapshot(snapshot);
return {
status: "success",
diff --git a/frontend/src/ts/components/mount.tsx b/frontend/src/ts/components/mount.tsx
index ada62540a141..1c83bf273b5d 100644
--- a/frontend/src/ts/components/mount.tsx
+++ b/frontend/src/ts/components/mount.tsx
@@ -13,7 +13,7 @@ import { Overlays } from "./layout/overlays/Overlays";
import { Modals } from "./modals/Modals";
import { NotFoundPage } from "./pages/404Page";
import { AboutPage } from "./pages/AboutPage";
-import { BlockedUsers } from "./pages/account-settings/BlockedUsers";
+import { AccountSettingsPage } from "./pages/account-settings/AccountSettingsPage";
import { AccountPage } from "./pages/account/AccountPage";
import { MyProfile } from "./pages/account/MyProfile";
import { FriendsPage } from "./pages/connections/FriendsPage";
@@ -46,8 +46,8 @@ const components: Record JSXElement> = {
commandlinehotkey: () => ,
testmodesnotice: () => ,
friendspage: () => ,
- blockedusers: () => ,
notfoundpage: () => ,
+ accountsettingspage: () => ,
};
function mountToMountpoint(name: string, component: () => JSXElement): void {
diff --git a/frontend/src/ts/components/pages/account-settings/AccountSettingsPage.tsx b/frontend/src/ts/components/pages/account-settings/AccountSettingsPage.tsx
new file mode 100644
index 000000000000..4ffb60f5d863
--- /dev/null
+++ b/frontend/src/ts/components/pages/account-settings/AccountSettingsPage.tsx
@@ -0,0 +1,57 @@
+import { For, JSXElement } from "solid-js";
+
+import {
+ AccountSettingsTab,
+ accountSettingsTabs,
+ getCurrentTab,
+ setCurrentTab,
+} from "../../../states/account-settings";
+import { Button } from "../../common/Button";
+import { Page } from "../../common/Page";
+import { AccountTab } from "./AccountTab";
+import { ApeKeysTab } from "./ApeKeysTab";
+import { AuthenticationTab } from "./AuthenticationTab";
+import { BlockedUsersTab } from "./BlockedUsersTab";
+import { DangerZoneTab } from "./DangerZoneTab";
+
+const tabContent: Record JSXElement> = {
+ account: () => ,
+ authentication: () => ,
+ blockedUsers: () => ,
+ apeKeys: () => ,
+ dangerZone: () => ,
+};
+
+export function AccountSettingsPage() {
+ return (
+
+
+
+
+
+
+ {tabContent[getCurrentTab()]()}
+
+
+
+ );
+}
+
+function Sidebar() {
+ return (
+
+
+ {([key, tab]) => (
+ setCurrentTab(key as AccountSettingsTab)}
+ />
+ )}
+
+
+ );
+}
diff --git a/frontend/src/ts/components/pages/account-settings/AccountTab.tsx b/frontend/src/ts/components/pages/account-settings/AccountTab.tsx
new file mode 100644
index 000000000000..1e42b86209f9
--- /dev/null
+++ b/frontend/src/ts/components/pages/account-settings/AccountTab.tsx
@@ -0,0 +1,180 @@
+import { Show } from "solid-js";
+
+import Ape from "../../../ape";
+import * as StreakHourOffsetModal from "../../../modals/streak-hour-offset";
+import { showLoaderBar } from "../../../states/loader-bar";
+import { showErrorNotification } from "../../../states/notifications";
+import { getSnapshot } from "../../../states/snapshot";
+import { Button } from "../../common/Button";
+import { Fa } from "../../common/Fa";
+import {
+ showOptOutOfLeaderboardsModal,
+ showResetPersonalBestsModal,
+} from "../../modals/account-settings/ReauthConfirmModals";
+import { showUnlinkDiscordModal } from "../../modals/account-settings/UnlinkDiscordModal";
+import { showUpdateNameModal } from "../../modals/account-settings/UpdateNameModal";
+import { Section } from "./utils";
+
+export function AccountTab() {
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+}
+
+function Discord() {
+ const isLinked = () => getSnapshot()?.discordId !== undefined;
+ return (
+
+ When you connect your monkeytype account to your Discord account, you
+ will be automatically assigned a new role every time you achieve a new
+ personal best in a 60 second test. If you link your accounts before
+ joining the Discord server, the bot will not give you a role.
+ >
+ button={
+ isLinked()
+ ? undefined
+ : {
+ text: "link",
+ onClick: () => {
+ showLoaderBar();
+ void Ape.users.getDiscordOAuth().then((response) => {
+ if (response.status === 200) {
+ window.open(response.body.data.url, "_self");
+ } else {
+ showErrorNotification(
+ `Failed to get OAuth from discord: ${response.body.message}`,
+ );
+ }
+ });
+ },
+ }
+ }
+ >
+
+
+
+ Your accounts are linked!
+
+
+ {
+ showLoaderBar();
+ void Ape.users.getDiscordOAuth().then((response) => {
+ if (response.status === 200) {
+ window.open(response.body.data.url, "_self");
+ } else {
+ showErrorNotification(
+ `Failed to get OAuth from discord: ${response.body.message}`,
+ );
+ }
+ });
+ }}
+ />
+ showUnlinkDiscordModal()}
+ />
+
+
+
+
+ );
+}
+
+function UpdateAccountName() {
+ return (
+
+ Change the name of your account.{" "}
+ You can only do this once every 30 days.
+ >
+ button={{
+ text: "update name",
+ onClick: () => showUpdateNameModal(),
+ }}
+ />
+ );
+}
+
+function UpdateStreakOffset() {
+ return (
+
+ Streaks reset at midnight UTC by default. If this is not convenient for
+ you (for example if it means that streaks reset in the middle of the
+ day), you can change the hour offset here.{" "}
+ You can only do this once!
+ >
+ button={{
+ text: "update hour offset",
+ onClick: () => StreakHourOffsetModal.show(),
+ }}
+ disabled={getSnapshot()?.streakHourOffset !== undefined}
+ disabledDescription=<>
+ You have already set your streak
+ hour offset to{" "}
+ {`${(getSnapshot()?.streakHourOffset ?? 0) > 0 ? "+" : ""} ${getSnapshot()?.streakHourOffset}`}
+ .
+ >
+ />
+ );
+}
+
+function OptOutLeaderboard() {
+ return (
+
+ Use this if you frequently trigger the anticheat (for example if using
+ stenography) to opt out of leaderboards.{" "}
+ You can't undo this action!
+ >
+ button={{
+ text: "opt out",
+ onClick: () => showOptOutOfLeaderboardsModal(),
+ }}
+ disabled={getSnapshot()?.lbOptOut === true}
+ disabledDescription=<>
+
+ You have opted out of leaderboards.
+ >
+ />
+ );
+}
+
+function ResetPersonalBests() {
+ return (
+
+ Resets all your personal bests (but doesn't delete any tests from
+ your history). You can't undo this!
+ >
+ button={{
+ text: "reset personal bests",
+ onClick: () => showResetPersonalBestsModal(),
+ }}
+ />
+ );
+}
diff --git a/frontend/src/ts/components/pages/account-settings/ApeKeysTab.tsx b/frontend/src/ts/components/pages/account-settings/ApeKeysTab.tsx
new file mode 100644
index 000000000000..ad981bb7bb8b
--- /dev/null
+++ b/frontend/src/ts/components/pages/account-settings/ApeKeysTab.tsx
@@ -0,0 +1,193 @@
+import { ApeKeyNameSchema } from "@monkeytype/schemas/ape-keys";
+import { createColumnHelper } from "@tanstack/solid-table";
+import { format as dateFormat } from "date-fns";
+import { createMemo, Show } from "solid-js";
+import { z } from "zod";
+
+import {
+ ApeKeyEntry,
+ insertApeKey,
+ removeApeKey,
+ renameApeKey,
+ updateApeKeyEnabled,
+ useApeKeyLiveQuery,
+} from "../../../collections/ape-keys";
+import { isApeKeysDenied } from "../../../states/account-settings";
+import { showModal } from "../../../states/modals";
+import { showSimpleModal } from "../../../states/simple-modal";
+import { replaceSpacesWithUnderscores } from "../../../utils/strings";
+import AsyncContent from "../../common/AsyncContent";
+import { Button } from "../../common/Button";
+import { Fa } from "../../common/Fa";
+import { DataTable, DataTableColumnDef } from "../../ui/table/DataTable";
+import { Section } from "./utils";
+
+export function ApeKeysTab() {
+ const columns = createMemo(() => getColumns());
+ const apeKeyQuery = useApeKeyLiveQuery();
+ return (
+ <>
+
+ Generate Ape Keys to access certain API endpoints (
+
+ ).
+ >
+ button={{
+ text: "generate new key",
+ onClick: addNewKey,
+ }}
+ disabled={isApeKeysDenied() === true}
+ disabledDescription=<>
+ You have lost access to Ape Keys. Please
+ contact support if you believe this is a mistake.
+ >
+ />
+
+
+ {({ apeKeyQueryData }) => (
+
+ You don‘t have any ape keys yet.
+
+ }
+ />
+ )}
+
+
+ >
+ );
+}
+
+function getColumns(): DataTableColumnDef