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/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..2707e9f --- /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: 10, + 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..fecc610 --- /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: 20, + 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 47bb7cd..e8147b6 100644 --- a/shared/errors.ts +++ b/shared/errors.ts @@ -29,7 +29,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; @@ -65,7 +68,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 7985900..73c9406 100644 --- a/src/layouts/docs.vue +++ b/src/layouts/docs.vue @@ -357,7 +357,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..ee9de5a 100644 --- a/src/locales/en_US.json +++ b/src/locales/en_US.json @@ -217,6 +217,11 @@ "settings": { "upgrade": "Upgrade account", "unavailable": "Unavailable", + "update": "Update", + "unverified_email": "Unverified", + "verified_email": "Verified", + "verify_email_notice": "You haven't verified your email address yet.", + "verify_email_button": "Verify now", "settingCards": { "userSettings": "User settings", "profile": "Profile", @@ -256,8 +261,24 @@ "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" } }, + "emailVerification": { + "verifiedTitle": "Email successfully verified", + "verifiedCaption": "You may now close this tab.", + "failedTitle": "Failed to verify email" + }, "accountLevel": [ "Standard", "Tester", diff --git a/src/pages/account/index.vue b/src/pages/account/index.vue index 4c8d91b..e09727d 100644 --- a/src/pages/account/index.vue +++ b/src/pages/account/index.vue @@ -435,58 +435,51 @@ customSeoMeta({ subsection: 'account' }); > {{ $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") }} +

    +

    + ●●●●●●●● +

    +
  • + +