Added account update form and e-mail verification.

This commit is contained in:
2026-07-22 05:49:39 +02:00
parent 17d34f6a80
commit 9b058fd44b
19 changed files with 1108 additions and 233 deletions
+106
View File
@@ -0,0 +1,106 @@
<script setup>
import {
Body,
Container,
Heading,
Html,
Img,
Preview,
Text,
} from '@vue-email/components';
import { format } from 'date-fns';
defineProps({
user: {
type: Object,
default: () => mockUser,
},
address: {
type: String,
default: 'http://localhost:5100',
},
ip: {
type: String,
default: '127.0.0.1',
},
country: {
type: String,
default: 'Unknown',
},
timestamp: {
type: Date,
default: () => new Date(),
},
});
</script>
<script>
// defineProps' default factory (above, in <script setup>) references this. It must live in a plain
// <script> block: Vue's compiler forbids defineProps from referencing variables declared inside
// <script setup> itself, since that block gets hoisted out of the setup() function.
const mockUser = {
id: 1,
username: 'DebuggingLibrarian',
email: 'debug@alerts.traxxx.me',
};
</script>
<template>
<Html>
<Body style="font-family: sans-serif;">
<Preview>Your traxxx password has been changed.</Preview>
<Container>
<Img
:src="`${address}/img/logo.png`"
width="150"
alt="traxxx"
style="margin: 15px auto 25px auto;"
/>
<Heading
as="h3"
style="color: #f65596; margin: 0 auto 0 auto;"
>Hi, {{ user.username }}</Heading>
<Heading
as="h3"
style="margin-top: 30px;"
>Your traxxx password was changed.</Heading>
<Text>Device used to change your password:</Text>
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="font-size: 14px;"
>
<tbody>
<tr>
<td style="padding: 4px 8px 4px 0;">IP:</td>
<td style="padding: 4px 0;">{{ ip }}</td>
</tr>
<tr>
<td style="padding: 4px 8px 4px 0;">Country:</td>
<td style="padding: 4px 0;">{{ country }}</td>
</tr>
<tr>
<td style="padding: 4px 8px 4px 0;">Timestamp:</td>
<td style="padding: 4px 0;">{{ format(timestamp, 'yyyy-MM-dd hh:mm:ss') }}</td>
</tr>
</tbody>
</table>
<Text style="font-size: 16px; margin: 20px 0;">If this change was made by you, you can ignore this e-mail. If this was not you, please <a
href="mailto:account@traxxx.me"
style="color: #f65596; text-decoration: none;"
>contact us</a> immediately.</Text>
</Container>
</Body>
</Html>
</template>
+85
View File
@@ -0,0 +1,85 @@
<script setup>
import {
Body,
Button,
Container,
Heading,
Html,
Img,
Preview,
Text,
} from '@vue-email/components';
import config from 'config';
defineProps({
user: {
type: Object,
default: () => mockUser,
},
verifyUrl: {
type: String,
default: 'http://localhost:5100/verify?userId=0&token=00000000000000000000000000000000',
},
trigger: {
type: String,
default: 'change',
},
address: {
type: String,
default: 'http://localhost:5100',
},
});
</script>
<script>
// defineProps' default factory (above, in <script setup>) references this. It must live in a plain
// <script> block: Vue's compiler forbids defineProps from referencing variables declared inside
// <script setup> itself, since that block gets hoisted out of the setup() function.
const mockUser = {
id: 1,
username: 'DebuggingLibrarian',
email: 'debug@alerts.traxxx.me',
};
</script>
<template>
<Html>
<Body style="font-family: sans-serif;">
<Preview>Please verify your traxxx e-mail address.</Preview>
<Container>
<Img
:src="`${address}/img/logo.png`"
width="150"
alt="traxxx"
style="margin: 15px auto 25px auto;"
/>
<Heading
as="h3"
style="color: #f65596; margin: 0 auto 0 auto;"
>Hi, {{ user.username }}</Heading>
<Text>
<strong v-if="trigger === 'signup'">Thank you for creating your traxxx account!</strong>
<strong v-else>You requested to {{ trigger === 'verify' ? 'verify' : 'change' }} the e-mail address for your traxxx account.</strong>
<br>
Please confirm your e-mail address by clicking the button below:
</Text>
<Button
:href="verifyUrl"
target="_blank"
style="background: #f65596; color: #fff; font-size: 16px; padding: 1rem 1.5rem; border-radius: .5rem; margin: 1rem 0;"
><strong>Verify e-mail address</strong></Button>
<Text style="font-size: 16px; margin: 20px 0;">
The link will expire in {{ config.auth.emailTokenExpiry }} minutes, or when you request an(other) e-mail change or verification.
<template v-if="trigger === 'signup'">If you did not sign up, you can safely ignore this e-mail.</template>
<template v-else>If you did not request this {{ trigger === 'verify' ? 'verification' : 'change' }}, you can safely ignore this e-mail.</template>
</Text>
</Container>
</Body>
</Html>
</template>
+92
View File
@@ -0,0 +1,92 @@
<script setup>
import { ref } from 'vue';
const props = defineProps({
password: {
type: String,
default: null,
},
placeholder: {
type: String,
default: 'Password',
},
autocomplete: {
type: String,
default: 'current-password',
},
});
const emit = defineEmits(['change', 'input']);
const currentPassword = ref(props.password);
const showPassword = ref(false);
</script>
<template>
<div class="password-container">
<input
v-model="currentPassword"
:type="showPassword ? 'input' : 'password'"
:placeholder="placeholder"
:autocomplete="autocomplete"
minlength="3"
class="password input"
@change="emit('change', currentPassword)"
@input="emit('input', currentPassword)"
>
<div
class="password-show"
@click="showPassword = !showPassword"
>
<Icon
v-show="!showPassword"
icon="eye"
class="password-show"
/>
<Icon
v-show="showPassword"
icon="eye-blocked"
class="password-show"
/>
</div>
</div>
</template>
<style scoped>
.input {
background: var(--background);
padding-right: 2.5rem;
}
.password-container {
display: flex;
position: relative;
}
.password {
flex-grow: 1;
}
.password-show {
height: 100%;
display: flex;
align-items: center;
position: absolute;
right: 0;
padding: 0 1rem 0 .5rem;
.icon {
fill: var(--glass);
}
&:hover {
cursor: pointer;
.icon {
fill: var(--primary);
}
}
}
</style>
+277
View File
@@ -0,0 +1,277 @@
<script setup>
import { inject, reactive, ref } from 'vue';
import Password from '#/components/form/password.vue';
import Ellipsis from '#/components/loading/ellipsis.vue';
// import Checkbox from '#/components/form/checkbox.vue';
import { patch, post } from '#/src/api.js';
const pageContext = inject('pageContext');
const { user } = pageContext;
const credentials = reactive({
email: null,
password: null,
});
const currentPassword = ref(null);
const saving = ref(false);
const submitted = ref(false);
const verifying = ref(false);
const verified = ref(false);
function getFeedback() {
if (credentials.email && Object.entries(credentials).some(([key, value]) => key !== 'email' && value !== null)) {
return 'Account updated, please check your inbox to change e-mail.';
}
if (credentials.email) {
return 'Please check your inbox to change e-mail.';
}
return 'Account updated.';
}
async function updateAccount() {
saving.value = true;
try {
await patch('/me', {
credentials,
password: currentPassword.value,
}, {
successFeedback: getFeedback(),
errorFeedback: 'Failed to update account',
appendErrorMessage: true,
});
submitted.value = true;
}
finally {
saving.value = false;
}
}
async function verifyEmail() {
verifying.value = true;
try {
await post('/me/email_verification', null, {
successFeedback: 'E-mail verification sent, please check your inbox',
errorFeedback: 'Failed to request e-mail verification',
appendErrorMessage: true,
});
verified.value = true;
}
finally {
verifying.value = false;
}
}
</script>
<template>
<form
class="profile-section"
@submit.prevent="updateAccount"
>
<h3 class="heading settings-heading">Alerts</h3>
<label class="setting">
<span class="setting-label">
<strong class="setting-title">E-mail address</strong>
<span class="setting-description">Primarily used for alert notifications and password recovery</span>
</span>
<span class="setting-value">
<span class="setting-group">
<VDropdown v-if="!user.isEmailVerified">
<Icon
icon="warning2"
class="unverified"
/>
<template #popper>
<div class="unverified-tooltip">
Your e-mail address is unverified
<Ellipsis v-if="verifying" />
<strong
v-else-if="verified"
class="success"
>Verification e-mail sent</strong>
<button
v-else
type="button"
class="button button-primary"
@click="verifyEmail"
>Verify now</button>
</div>
</template>
</VDropdown>
<input
:value="user.email"
type="email"
class="input"
disabled
>
</span>
<input
v-model="credentials.email"
type="email"
placeholder="New e-mail"
class="input"
>
</span>
</label>
<label class="setting">
<span class="setting-label">
<strong class="setting-title">New password</strong>
<span class="setting-description">Change the password you use for signing in</span>
</span>
<span class="setting-value">
<Password
:password="credentials.password"
placeholder="New password"
autocomplete="new-password"
@input="(password) => credentials.password = password"
/>
</span>
</label>
<label class="setting">
<span class="setting-label">
<strong class="setting-title">Current password</strong>
<span class="setting-description">Enter your current password to save your changes</span>
</span>
<span class="setting-value">
<Password
:password="currentPassword"
placeholder="Current password"
@input="(password) => currentPassword = password"
/>
</span>
</label>
<div class="settings-actions">
<Ellipsis v-if="saving" />
<strong
v-else-if="submitted"
class="success"
>{{ getFeedback() }}</strong>
<button
v-else
v-tooltip="(credentials.email || credentials.password) && currentPassword ? null : 'Please supply your new credentials and current password'"
class="button button-primary"
:disabled="!currentPassword"
>Save</button>
</div>
</form>
</template>
<style scoped>
.input {
flex-shrink: 0;
flex-basis: auto;
background: var(--background);
&:disabled {
color: var(--glass);
background: none;
}
}
.profile-section .heading {
margin: .5rem 1rem 0 1rem;
}
.setting {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
box-sizing: border-box;
padding: .75rem 1rem;
border-bottom: solid 1px var(--glass-weak-30);
&:hover {
border-color: var(--glass-weak-20);
}
}
.setting-label {
display: flex;
flex-direction: column;
}
.setting-title {
color: var(--glass-strong-20);
margin-bottom: .25rem;
}
.setting-value {
display: flex;
align-items: flex-end;
flex-direction: column;
gap: .5rem;
}
.setting-group {
display: flex;
align-items: center;
}
.setting-description {
color: var(--glass);
font-size: .9rem;
}
.settings-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 1rem;
.button {
flex-basis: 0;
font-size: 1.1rem;
padding: .5rem 1rem;
}
}
.success {
color: var(--success);
margin-top: 1rem;
text-align: center;
}
.unverified {
fill: var(--error);
padding: .5rem 1rem;
cursor: pointer;
}
.unverified-tooltip {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.load-container {
display: flex;
justify-content: center;
}
</style>