Merge pull request #454 from PretendoNetwork/feat/email-editing
Some checks failed
Build and Publish Docker Image / Build and Publish (amd64) (push) Has been cancelled
Build and Publish Docker Image / Build and Publish (arm64) (push) Has been cancelled

feat: implement email editing/verification endpoint
This commit is contained in:
mrjvs
2026-08-31 21:21:02 +02:00
committed by GitHub
15 changed files with 491 additions and 63 deletions

View File

@@ -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

8
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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<string, ApiErrorCodes> = {
'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<void> => {
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;
}
});

View File

@@ -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<string, ApiErrorCodes> = {
'INVALID_ARGUMENT: Missing email token': 'MISSING_EMAIL_TOKEN',
'INVALID_ARGUMENT: Invalid email token': 'INVALID_EMAIL_TOKEN'
};
export default defineEventHandler(async (event): Promise<void> => {
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;
}
});

View File

@@ -16,6 +16,7 @@ export default defineEventHandler(async (event): Promise<GetApiAuthMe> => {
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

View File

@@ -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<typeof AccountUpdateSchema>;
export const EmailUpdateSchema = z.object({
email: z.string()
});
export type ApiAccountEmailUpdateRequest = z.infer<typeof EmailUpdateSchema>;
export const EmailVerifySchema = z.object({
token: z.string()
});
export type ApiAccountEmailVerifyRequest = z.infer<typeof EmailVerifySchema>;
export const ResetPasswordSchema = z.object({
password: z.string(),
passwordConfirm: z.string(),

View File

@@ -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<ApiErrorCodes, number> = {
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 {

View File

@@ -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;

View File

@@ -88,7 +88,7 @@ function handleOpenUpdate(id: number, open: boolean) {
<style>
.toast-viewport {
z-index: 55;
z-index: 105;
display: flex;
justify-content: center;
position: fixed;

View File

@@ -0,0 +1,210 @@
<script setup lang="ts">
import { Dialog } from 'reka-ui/namespaced';
import type { ApiAccountEmailUpdateRequest, GetApiAuthMe } from '~~/shared/api-types';
const toasts = useToasts();
const open = defineModel<boolean>();
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<{
profile: GetApiAuthMe;
dialogContainer: HTMLElement | null;
}>();
const emit = defineEmits<{
change: [];
}>();
const {
execute: executeUpdateEmail,
isLoading: isLoadingUpdateEmail
} = useAsync({
async handler(body: ApiAccountEmailUpdateRequest) {
await apiFetch('/api/account/update-email', {
method: 'PATCH',
body: {
email: body.email
} satisfies ApiAccountEmailUpdateRequest
});
open.value = false;
emit('change');
toasts.publish({
type: 'success',
text: 'A confirmation email has been sent to your inbox.'
});
},
onError(error) {
const err = getApiError(error);
toasts.publish({
type: 'error',
text: err.message
});
}
});
</script>
<template>
<Dialog.Root
v-model:open="updateOpen"
>
<Dialog.Portal :to="dialogContainer ?? undefined">
<Dialog.Overlay />
<Dialog.Content
class="modal"
@interact-outside="(e) => {
if (isLoadingUpdateEmail) {
return e.preventDefault();
}
}"
>
<Dialog.Title>
{{ $t("account.settings.emailModal.title") }}.
</Dialog.Title>
<Dialog.Description class="modal-caption">
<p>
{{
$t("account.settings.emailModal.caption")
}}
</p>
</Dialog.Description>
<fieldset class="account-edit">
<input
id="new-email"
v-model="newEmail"
name="new-email"
type="email"
required
>
</fieldset>
<div class="modal-button-wrapper">
<Dialog.Close
class="cancel"
:disabled="isLoadingUpdateEmail"
>
{{ $t("modals.cancel") }}
</Dialog.Close>
<button
:class="{action: true, disabled: isLoadingUpdateEmail || !newEmail}"
:disabled="isLoadingUpdateEmail || !newEmail"
@click="() => executeUpdateEmail({
email: newEmail
})"
>
<Loader v-if="isLoadingUpdateEmail" />
<span v-else>{{
$t("modals.confirm")
}}</span>
</button>
</div>
</Dialog.Content>
</Dialog.Portal><Dialog.Trigger class="update-trigger">
{{ $t('account.settings.update') }}
</Dialog.Trigger>
</Dialog.Root>
<Dialog.Root
v-if="!profile.emailValidated"
v-model:open="verifyOpen"
>
<Dialog.Portal :to="dialogContainer ?? undefined">
<Dialog.Overlay />
<Dialog.Content
class="modal"
>
<Dialog.Title>
{{ $t("account.settings.unverifiedEmailModal.title") }}.
</Dialog.Title>
<Dialog.Description class="modal-caption">
<p>
{{
$t("account.settings.unverifiedEmailModal.caption")
}}
</p>
<p>
{{ $t('account.settings.unverifiedEmailModal.lostEmail') }}
<button
:class="{
'update-trigger':
true,
disabled:
isLoadingUpdateEmail
}"
:disabled="isLoadingUpdateEmail"
@click="() => executeUpdateEmail({
email: profile.emailAddress
})"
>
<Loader v-if="isLoadingUpdateEmail" />
<span v-else>
{{
$t("account.settings.unverifiedEmailModal.resend")
}}
</span>
</button>
</p>
</Dialog.Description>
<div class="modal-button-wrapper">
<Dialog.Close
class="cancel"
:disabled="isLoadingUpdateEmail"
>
{{ $t("modals.close") }}
</Dialog.Close>
</div>
</Dialog.Content>
</Dialog.Portal>
<p class="notice">
{{ $t('account.settings.verify_email_notice') }}
<br>
<Dialog.Trigger class="unverified">
{{ $t('account.settings.verify_email_button') }}
</Dialog.Trigger>
</p>
</Dialog.Root>
</template>
<style>
.update-trigger {
all: unset;
color: var(--accent-shade-1);
font-weight: bold;
cursor: pointer;
}
.unverified {
all: unset;
color: var(--accent-shade-1);
font-weight: bold;
cursor: pointer;
}
.update-trigger:hover,
.unverified:hover {
background: none;
text-decoration: underline;
}
fieldset {
position: relative;
display: flex;
flex-flow: column;
height: min-content;
padding: 0;
gap: .25rem;
border: none;
}
.notice {
font-size: .9em;
margin-top: 1rem !important;
}
</style>

View File

@@ -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);

View File

@@ -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",

View File

@@ -435,58 +435,51 @@ customSeoMeta({ subsection: 'account' });
>
{{ $t("account.settings.settingCards.signInSecurity") }}
</h2>
<AlertDialog.Root v-model:open="emailEditModalOpen">
<div class="setting-card">
<h2 class="header">
{{ $t("account.account") }}
</h2>
<AlertDialog.Trigger class="edit">
<Icon
name="ph:pencil"
size="26"
<div class="setting-card">
<h2 class="header">
{{ $t("account.account") }}
</h2>
<ul class="setting-list">
<li>
<p class="label">
{{ $t("account.settings.settingCards.email") }}
<span
v-if="profile.emailValidated"
class="tag"
><Icon
name="ph:check-circle-fill"
size="16"
/>{{ $t('account.settings.verified_email') }}</span>
<span
v-else
class="tag warn"
><Icon
name="ph:warning-circle-fill"
size="16"
/>{{ $t('account.settings.unverified_email') }}</span>
</p>
<p class="value">
{{ profile.emailAddress }}
</p>
<UpdateEmailModal
v-model="emailEditModalOpen"
:profile="profile"
:dialog-container="dialogContainer"
@change="refresh"
/>
</AlertDialog.Trigger>
<AlertDialog.Portal :to="dialogContainer ?? undefined">
<AlertDialog.Overlay />
<AlertDialog.Content class="modal">
<AlertDialog.Title>
{{ $t("account.settings.unavailable") }}.
</AlertDialog.Title>
<AlertDialog.Description class="modal-caption">
<p>
{{
$t("account.settings.settingCards.no_edit_from_dashboard")
}}
</p>
</AlertDialog.Description>
<div class="modal-button-wrapper">
<AlertDialog.Cancel class="cancel">
{{ $t("modals.close") }}
</AlertDialog.Cancel>
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
<ul class="setting-list">
<li>
<p class="label">
{{ $t("account.settings.settingCards.email") }}
</p>
<p class="value">
{{ profile.emailAddress }}
</p>
</li>
<li>
<p class="label">
{{ $t("account.settings.settingCards.password") }}
</p>
<p class="value">
●●●●●●●●
</p>
</li>
</ul>
<p>{{ $t("account.settings.settingCards.passwordResetNotice") }}</p>
</div>
</AlertDialog.Root>
</li>
<li>
<p class="label">
{{ $t("account.settings.settingCards.password") }}
</p>
<p class="value">
●●●●●●●●
</p>
</li>
</ul>
</div>
<div class="setting-card sign-in-history">
<h2 class="header">
@@ -530,7 +523,7 @@ customSeoMeta({ subsection: 'account' });
</button>
<p v-else>
{{ $t("account.settings.settingCards.noDiscordLinked") }}
<NuxtLink
<br><NuxtLink
:style="{ cursor: 'pointer' }"
@click="executeLinkDiscord"
>
@@ -788,10 +781,32 @@ customSeoMeta({ subsection: 'account' });
padding: 0;
}
.setting-card .setting-list p.label {
display: flex;
align-items: center;
gap: .5em;
color: var(--text-shade-3);
margin-bottom: 4px;
}
.setting-card .setting-list p.label .tag {
background: var(--bg-shade-0);
display: flex;
align-items: center;
gap: .3em;
border-radius: 9999px;
padding: 3px 10px 3px 6px;
font-size: .8em;
}
.setting-card .setting-list p.label .tag span {
color: var(--green-shade-1);
margin-top: 2px
}
.setting-card .setting-list p.label .tag.warn span {
color: var(--yellow-shade-1);
}
.modal-wrapper {
padding: 1.5rem;
box-sizing: border-box;

View File

@@ -0,0 +1,84 @@
<script setup lang="ts">
import type { ApiAccountEmailVerifyRequest } from '~~/shared/api-types';
const route = useRoute();
const token = route.query.token?.toString() || '';
const success = ref(false);
const errorMessage = ref('');
const {
execute: executeVerifyEmail,
isLoading: isLoadingVerifyEmail
} = useAsync({
async handler() {
await apiFetch('/api/account/verify-email', {
method: 'POST',
body: {
token: token
} satisfies ApiAccountEmailVerifyRequest
});
success.value = true;
},
onError(error) {
const err = getApiError(error);
errorMessage.value = err.message;
}
});
callOnce(() => {
executeVerifyEmail();
});
</script>
<template>
<div v-if="isLoadingVerifyEmail">
<div class="account-form-wrapper loading">
<Loader />
</div>
</div>
<div v-else-if="success">
<div class="account-form-wrapper">
<h1
class="title dot"
>
{{ $t('account.emailVerification.verifiedTitle') }}
</h1>
<p>{{ $t('account.emailVerification.verifiedCaption') }}</p>
</div>
</div>
<div v-else>
<div class="account-form-wrapper">
<h1 class="title dot">
{{ $t('account.emailVerification.failedTitle') }}
</h1>
<p>{{ errorMessage }}</p>
</div>
</div>
</template>
<style scoped>
.account-form-wrapper {
min-height: 60vh;
display: flex;
align-content: center;
flex-direction: column;
margin: 15vh auto;
width: -moz-fit-content;
width: fit-content;
overflow: hidden;
}
.account-form-wrapper.loading {
justify-content: center;
}
.title {
font-size: 4rem;
margin-bottom: 0;
}
@media screen and (max-width: 1000px) {
.title {
font-size: 2rem;
}
}
</style>