From 85d858612fa6ba22a12715900c91ec0ed5388756 Mon Sep 17 00:00:00 2001 From: limes Date: Wed, 26 Aug 2026 21:19:47 +0200 Subject: [PATCH 1/6] feat: implement email editing/verification endpoint --- package-lock.json | 8 +- package.json | 2 +- server/api/account/update-email.patch.ts | 38 +++++ server/api/account/verify-email.post.ts | 36 +++++ server/api/auth/me.get.ts | 1 + shared/api-types.ts | 11 ++ shared/errors.ts | 10 +- src/assets/css/main.css | 8 +- src/components/ToastRenderer.vue | 2 +- src/components/UpdateEmailModal.vue | 188 ++++++++++++++++++++++ src/layouts/docs.vue | 2 +- src/locales/en_US.json | 15 ++ src/pages/account/index.vue | 119 ++++++++------ src/pages/account/verify-email.client.vue | 86 ++++++++++ 14 files changed, 464 insertions(+), 62 deletions(-) create mode 100644 server/api/account/update-email.patch.ts create mode 100644 server/api/account/verify-email.post.ts create mode 100644 src/components/UpdateEmailModal.vue create mode 100644 src/pages/account/verify-email.client.vue diff --git a/package-lock.json b/package-lock.json index 27e3168..79e816b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "@nuxtjs/i18n": "^10.6.0", "@pinia/nuxt": "^1.0.1", "@pretendonetwork/error-codes": "^1.2.2", - "@pretendonetwork/grpc": "^2.5.7", + "@pretendonetwork/grpc": "^2.5.10", "@pretendonetwork/mii-js": "^1.0.11", "@vueuse/core": "^14.4.0", "better-sqlite3": "^12.11.1", @@ -5347,9 +5347,9 @@ } }, "node_modules/@pretendonetwork/grpc": { - "version": "2.5.7", - "resolved": "https://registry.npmjs.org/@pretendonetwork/grpc/-/grpc-2.5.7.tgz", - "integrity": "sha512-HmWyBxm/Om6S5k+PYLxVU/MaRcSUEV0NAfKccy4i02ZJTZccCJRRKiSBl3WfXSYE+TJNJhSoURGK56E1i8CXqA==", + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@pretendonetwork/grpc/-/grpc-2.5.10.tgz", + "integrity": "sha512-1nd/nsRU+tdi08O6fHFuYvUJqByJh/qToQxvsDgWonwZLR/RsBFi3o2MU1/4ketbeGu2KmStLN7vBLDcQ+c44w==", "license": "AGPL-3.0-only", "dependencies": { "@bufbuild/protobuf": "^2.2.2", diff --git a/package.json b/package.json index fff33eb..dafd3cf 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "@nuxtjs/i18n": "^10.6.0", "@pinia/nuxt": "^1.0.1", "@pretendonetwork/error-codes": "^1.2.2", - "@pretendonetwork/grpc": "^2.5.7", + "@pretendonetwork/grpc": "^2.5.10", "@pretendonetwork/mii-js": "^1.0.11", "@vueuse/core": "^14.4.0", "better-sqlite3": "^12.11.1", diff --git a/server/api/account/update-email.patch.ts b/server/api/account/update-email.patch.ts new file mode 100644 index 0000000..80929ff --- /dev/null +++ b/server/api/account/update-email.patch.ts @@ -0,0 +1,38 @@ +import { ClientError } from 'nice-grpc'; +import { EmailUpdateSchema } from '~~/shared/api-types'; +import type { ApiErrorCodes } from '~~/shared/errors'; + +const bucket = createRatelimitBucket({ + id: 'update-email', + points: 3, + durationSec: 30 * 60, // 30 minutes + blockDurationSec: 1 * 60 * 60 // 1 hour +}); + +const errors: Record = { + 'INVALID_ARGUMENT: Must provide new email address': 'UNPARSABLE_ERROR', + 'INVALID_ARGUMENT: Invalid email address': 'INVALID_EMAIL', + 'INVALID_ARGUMENT: New email address must differ from current': 'EMAIL_UNCHANGED' +}; + +export default defineEventHandler(async (event): Promise => { + await enforceRatelimit(event, bucket); + + const body = await readZodBody(event, EmailUpdateSchema); + const auth = enforceLoggedIn(event); + const grpc = useApiGrpcWithToken(event, auth.token); + + try { + await grpc.updateEmail({ + email: body.email + }); + } catch (error: unknown) { + if (error instanceof ClientError) { + const errorCode = errors[error.details]; + if (errorCode) { + throw createApiError(errorCode); + } + } + throw error; + } +}); diff --git a/server/api/account/verify-email.post.ts b/server/api/account/verify-email.post.ts new file mode 100644 index 0000000..d5f67cb --- /dev/null +++ b/server/api/account/verify-email.post.ts @@ -0,0 +1,36 @@ +import { ClientError } from 'nice-grpc'; +import { EmailVerifySchema } from '~~/shared/api-types'; +import type { ApiErrorCodes } from '~~/shared/errors'; + +const bucket = createRatelimitBucket({ + id: 'verify-email', + points: 3, + durationSec: 30 * 60, // 30 minutes + blockDurationSec: 1 * 60 * 60 // 1 hour +}); + +const errors: Record = { + 'INVALID_ARGUMENT: Missing email token': 'MISSING_EMAIL_TOKEN', + 'INVALID_ARGUMENT: Invalid email token': 'INVALID_EMAIL_TOKEN' +}; + +export default defineEventHandler(async (event): Promise => { + await enforceRatelimit(event, bucket); + + const body = await readZodBody(event, EmailVerifySchema); + const grpc = useApiGrpc(event); + + try { + await grpc.verifyEmail({ + token: body.token + }); + } catch (error: unknown) { + if (error instanceof ClientError) { + const errorCode = errors[error.details]; + if (errorCode) { + throw createApiError(errorCode); + } + } + throw error; + } +}); diff --git a/server/api/auth/me.get.ts b/server/api/auth/me.get.ts index 0a64fb9..0dfc3d4 100644 --- a/server/api/auth/me.get.ts +++ b/server/api/auth/me.get.ts @@ -16,6 +16,7 @@ export default defineEventHandler(async (event): Promise => { region: data.region, timezone: data.timezone, emailAddress: data.emailAddress, + emailValidated: data.emailValidated, serverAccessLevel: data.serverAccessLevel as any, discordId: data.connections?.discord?.id ?? null, stripeTier: data.connections?.stripe?.subscriptionId diff --git a/shared/api-types.ts b/shared/api-types.ts index 200464f..aa6b42e 100644 --- a/shared/api-types.ts +++ b/shared/api-types.ts @@ -10,6 +10,7 @@ export type GetApiAuthMe = { region: number; timezone: string; emailAddress: string; + emailValidated: boolean; serverAccessLevel: 'dev' | 'test' | 'prod'; discordId: string | null; stripeTier: { @@ -117,6 +118,16 @@ export const AccountUpdateSchema = z.object({ }); export type ApiAccountUpdateRequest = z.infer; +export const EmailUpdateSchema = z.object({ + email: z.string() +}); +export type ApiAccountEmailUpdateRequest = z.infer; + +export const EmailVerifySchema = z.object({ + token: z.string() +}); +export type ApiAccountEmailVerifyRequest = z.infer; + export const ResetPasswordSchema = z.object({ password: z.string(), passwordConfirm: z.string(), diff --git a/shared/errors.ts b/shared/errors.ts index bd83c98..5e5e95e 100644 --- a/shared/errors.ts +++ b/shared/errors.ts @@ -26,7 +26,10 @@ const apiErrorCodes = { INVALID_GENDER: 'Invalid gender', INVALID_REGION: 'Invalid region', INVALID_TIMEZONE: 'Invalid timezone', - INVALID_MII_DATA: 'Invalid mii data' + INVALID_MII_DATA: 'Invalid mii data', + EMAIL_UNCHANGED: 'New email address must differ from current', + INVALID_EMAIL_TOKEN: 'Invalid email token', + MISSING_EMAIL_TOKEN: 'Missing email token' } as const; export type ApiErrorCodes = keyof typeof apiErrorCodes; @@ -59,7 +62,10 @@ export const apiErrorCodeStatus: Record = { INVALID_GENDER: 400, INVALID_REGION: 400, INVALID_TIMEZONE: 400, - INVALID_MII_DATA: 400 + INVALID_MII_DATA: 400, + EMAIL_UNCHANGED: 400, + INVALID_EMAIL_TOKEN: 400, + MISSING_EMAIL_TOKEN: 400 }; export function getTextForApiErrorCode(code: ApiErrorCodes): string { diff --git a/src/assets/css/main.css b/src/assets/css/main.css index afadafd..b8e3085 100644 --- a/src/assets/css/main.css +++ b/src/assets/css/main.css @@ -25,7 +25,9 @@ --red-shade-2: #e84059; --red-shade-1: #a9375b; - --yellow-shade-1: #ffd966; + + --yellow-shade-2: #ffd966; + --yellow-shade-1: #daa401; --border: rgba(255, 255, 255, 0.1); } @@ -185,6 +187,10 @@ div.modal h2 { .modal-button-wrapper button.action { background: var(--green-shade-0); } +.modal-button-wrapper button.action.disabled { + filter:saturate(0.7) brightness(0.8); + pointer-events: none; +} .purple-card { border-radius: 10px; diff --git a/src/components/ToastRenderer.vue b/src/components/ToastRenderer.vue index 5cff845..0179303 100644 --- a/src/components/ToastRenderer.vue +++ b/src/components/ToastRenderer.vue @@ -88,7 +88,7 @@ function handleOpenUpdate(id: number, open: boolean) { diff --git a/src/layouts/docs.vue b/src/layouts/docs.vue index 45e36b9..85b002a 100644 --- a/src/layouts/docs.vue +++ b/src/layouts/docs.vue @@ -365,7 +365,7 @@ button#openSidebar { opacity: 1; } .docs-wrapper :deep(.content) .content-inner div.tip.yellow::after { - background: var(--yellow-shade-1); + background: var(--yellow-shade-2); } .docs-wrapper :deep(.content) .content-inner div.tip.red::after { background: var(--red-shade-1); diff --git a/src/locales/en_US.json b/src/locales/en_US.json index c74fd6f..1c23134 100644 --- a/src/locales/en_US.json +++ b/src/locales/en_US.json @@ -217,6 +217,10 @@ "settings": { "upgrade": "Upgrade account", "unavailable": "Unavailable", + "update": "Update", + "unverified_email": "Unverified", + "verified_email": "Verified", + "verify_email": "Verify email", "settingCards": { "userSettings": "User settings", "profile": "Profile", @@ -256,6 +260,17 @@ "modalDescription": "Are you sure you want to delete your PNID? Please consider the following before deletion:\n\nYour account data across all Pretendo Network services (this includes Forum and Juxtaposition) will be erased.\nYour Stripe data and subscription will be automatically deleted.\nYou will not be able to use the same PNID on a new account in the future.\nDeleting an account does not solve issues with bans or technical support. If you have an issue please use the Forum for assistance.", "modalCaution": "This action cannot be undone.", "modalConfirm": "Yes, delete" + }, + "emailModal": { + "title": "Change email", + "caption": "Input your new email address.", + "label": "New email" + }, + "unverifiedEmailModal": { + "title": "Verify email", + "caption": "To verify your email address, click on the link in the verification email.", + "lostEmail": "Can't find it?", + "resend": "Resend verification email" } }, "accountLevel": [ diff --git a/src/pages/account/index.vue b/src/pages/account/index.vue index 363a930..37a5240 100644 --- a/src/pages/account/index.vue +++ b/src/pages/account/index.vue @@ -425,58 +425,51 @@ useHead({ > {{ $t("account.settings.settingCards.signInSecurity") }} - -
-

- {{ $t("account.account") }} -

- - +

+ {{ $t("account.account") }} +

+ +
    +
  • +

    + {{ $t("account.settings.settingCards.email") }} + {{ $t('account.settings.verified_email') }} + {{ $t('account.settings.unverified_email') }} +

    +

    + {{ profile.emailAddress }} +

    + - - - - - - {{ $t("account.settings.unavailable") }}. - - -

    - {{ - $t("account.settings.settingCards.no_edit_from_dashboard") - }} -

    -
    - -
    -
    -
      -
    • -

      - {{ $t("account.settings.settingCards.email") }} -

      -

      - {{ profile.emailAddress }} -

      -
    • -
    • -

      - {{ $t("account.settings.settingCards.password") }} -

      -

      - ●●●●●●●● -

      -
    • -
    -

    {{ $t("account.settings.settingCards.passwordResetNotice") }}

    -
-
+ +
  • +

    + {{ $t("account.settings.settingCards.password") }} +

    +

    + ●●●●●●●● +

    +
  • + +
    From f7586648a5414ea51ff285b8b8a7a7035f2e1aa8 Mon Sep 17 00:00:00 2001 From: limes Date: Sat, 29 Aug 2026 18:19:19 +0200 Subject: [PATCH 5/6] feat: bump ratelimits --- server/api/account/update-email.patch.ts | 2 +- server/api/account/verify-email.post.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/api/account/update-email.patch.ts b/server/api/account/update-email.patch.ts index 80929ff..2707e9f 100644 --- a/server/api/account/update-email.patch.ts +++ b/server/api/account/update-email.patch.ts @@ -4,7 +4,7 @@ import type { ApiErrorCodes } from '~~/shared/errors'; const bucket = createRatelimitBucket({ id: 'update-email', - points: 3, + points: 10, durationSec: 30 * 60, // 30 minutes blockDurationSec: 1 * 60 * 60 // 1 hour }); diff --git a/server/api/account/verify-email.post.ts b/server/api/account/verify-email.post.ts index d5f67cb..fecc610 100644 --- a/server/api/account/verify-email.post.ts +++ b/server/api/account/verify-email.post.ts @@ -4,7 +4,7 @@ import type { ApiErrorCodes } from '~~/shared/errors'; const bucket = createRatelimitBucket({ id: 'verify-email', - points: 3, + points: 20, durationSec: 30 * 60, // 30 minutes blockDurationSec: 1 * 60 * 60 // 1 hour }); From 072e3c131ec6b4e12be45274b2062e6192b045cd Mon Sep 17 00:00:00 2001 From: limes Date: Sat, 29 Aug 2026 23:46:33 +0200 Subject: [PATCH 6/6] fix: requested changes --- .docker/docker-compose.yml | 2 +- src/components/UpdateEmailModal.vue | 54 +++++++++++++++++------ src/locales/en_US.json | 3 +- src/pages/account/verify-email.client.vue | 15 +++---- 4 files changed, 50 insertions(+), 24 deletions(-) diff --git a/.docker/docker-compose.yml b/.docker/docker-compose.yml index 885cb56..d69973d 100644 --- a/.docker/docker-compose.yml +++ b/.docker/docker-compose.yml @@ -67,7 +67,7 @@ services: volumes: - "./assets/garage-init.sh:/etc/init.sh" account: - image: ghcr.io/pretendonetwork/account:sha-81cbacf + image: ghcr.io/pretendonetwork/account:sha-34dc075 restart: unless-stopped networks: - net diff --git a/src/components/UpdateEmailModal.vue b/src/components/UpdateEmailModal.vue index d3afffc..b4bddb3 100644 --- a/src/components/UpdateEmailModal.vue +++ b/src/components/UpdateEmailModal.vue @@ -5,6 +5,16 @@ import type { ApiAccountEmailUpdateRequest, GetApiAuthMe } from '~~/shared/api-t const toasts = useToasts(); const open = defineModel(); +const updateOpen = ref(false); +const verifyOpen = ref(false); + +watch(updateOpen, () => { + open.value = updateOpen.value; +}); +watch(verifyOpen, () => { + open.value = verifyOpen.value; +}); + const newEmail = ref(''); const { profile } = defineProps<{ @@ -19,11 +29,11 @@ const { execute: executeUpdateEmail, isLoading: isLoadingUpdateEmail } = useAsync({ - async handler() { + async handler(body: ApiAccountEmailUpdateRequest) { await apiFetch('/api/account/update-email', { method: 'PATCH', body: { - email: newEmail.value + email: body.email } satisfies ApiAccountEmailUpdateRequest }); @@ -47,8 +57,7 @@ const { diff --git a/src/locales/en_US.json b/src/locales/en_US.json index a1b2c79..ee9de5a 100644 --- a/src/locales/en_US.json +++ b/src/locales/en_US.json @@ -220,7 +220,8 @@ "update": "Update", "unverified_email": "Unverified", "verified_email": "Verified", - "verify_email": "Verify email", + "verify_email_notice": "You haven't verified your email address yet.", + "verify_email_button": "Verify now", "settingCards": { "userSettings": "User settings", "profile": "Profile", diff --git a/src/pages/account/verify-email.client.vue b/src/pages/account/verify-email.client.vue index 15484d4..270c183 100644 --- a/src/pages/account/verify-email.client.vue +++ b/src/pages/account/verify-email.client.vue @@ -27,19 +27,15 @@ const { } }); -await callOnce(async () => { - await executeVerifyEmail(); +callOnce(() => { + executeVerifyEmail(); });