mirror of
https://github.com/PretendoNetwork/website.git
synced 2026-09-13 19:37:37 -05:00
Merge pull request #454 from PretendoNetwork/feat/email-editing
feat: implement email editing/verification endpoint
This commit is contained in:
@@ -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
8
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
38
server/api/account/update-email.patch.ts
Normal file
38
server/api/account/update-email.patch.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
36
server/api/account/verify-email.post.ts
Normal file
36
server/api/account/verify-email.post.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
210
src/components/UpdateEmailModal.vue
Normal file
210
src/components/UpdateEmailModal.vue
Normal 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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
84
src/pages/account/verify-email.client.vue
Normal file
84
src/pages/account/verify-email.client.vue
Normal 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>
|
||||
Reference in New Issue
Block a user