Added account update form and e-mail verification.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
.input {
|
||||
width: 15rem;
|
||||
box-sizing: border-box;
|
||||
padding: .5rem .75rem;
|
||||
font-size: 1rem;
|
||||
|
||||
106
components/emails/password.vue
Normal file
106
components/emails/password.vue
Normal 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
components/emails/verify.vue
Normal file
85
components/emails/verify.vue
Normal 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
components/form/password.vue
Normal file
92
components/form/password.vue
Normal 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
components/user/account.vue
Normal file
277
components/user/account.vue
Normal 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>
|
||||
@@ -154,6 +154,7 @@ module.exports = {
|
||||
siteKey: '10000000-ffff-ffff-ffff-000000000001',
|
||||
secretKey: '0x0000000000000000000000000000000000000000',
|
||||
},
|
||||
emailTokenExpiry: 120, // minutes
|
||||
},
|
||||
bans: {
|
||||
defaultExpiry: 60 * 24 * 3, // in minutes, 3 days
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup>
|
||||
import { inject, onMounted, ref } from 'vue';
|
||||
|
||||
import Password from '#/components/form/password.vue';
|
||||
|
||||
import { post } from '#/src/api.js';
|
||||
import navigate from '#/src/navigate.js';
|
||||
|
||||
@@ -14,7 +16,6 @@ const password = ref('');
|
||||
const submitted = ref(false);
|
||||
const errorMsg = ref(null);
|
||||
const userInput = ref(null);
|
||||
const showPassword = ref(false);
|
||||
|
||||
async function login() {
|
||||
submitted.value = true;
|
||||
@@ -88,31 +89,10 @@ onMounted(() => {
|
||||
class="input"
|
||||
>
|
||||
|
||||
<div class="password-container">
|
||||
<input
|
||||
v-model="password"
|
||||
:type="showPassword ? 'input' : 'password'"
|
||||
placeholder="Password"
|
||||
class="password input"
|
||||
>
|
||||
|
||||
<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>
|
||||
<Password
|
||||
:password="password"
|
||||
@input="(newPassword) => password = newPassword"
|
||||
/>
|
||||
|
||||
<button
|
||||
class="button button-submit"
|
||||
@@ -129,6 +109,10 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import VueHCaptcha from '@hcaptcha/vue3-hcaptcha';
|
||||
import { inject, onMounted, ref } from 'vue';
|
||||
|
||||
import Password from '#/components/form/password.vue';
|
||||
|
||||
import { post } from '#/src/api.js';
|
||||
import navigate from '#/src/navigate.js';
|
||||
|
||||
@@ -16,7 +18,6 @@ const passwordConfirm = ref('');
|
||||
const errorMsg = ref(null);
|
||||
const submitted = ref(false);
|
||||
const userInput = ref(null);
|
||||
const showPassword = ref(false);
|
||||
const captcha = ref(null);
|
||||
|
||||
async function signup() {
|
||||
@@ -113,63 +114,18 @@ onMounted(() => {
|
||||
required
|
||||
>
|
||||
|
||||
<div class="password-container">
|
||||
<input
|
||||
v-model="password"
|
||||
:type="showPassword ? 'input' : 'password'"
|
||||
autocomplete="new-password"
|
||||
minlength="3"
|
||||
placeholder="Password"
|
||||
class="password input"
|
||||
required
|
||||
>
|
||||
<Password
|
||||
:password="password"
|
||||
autocomplete="new-password"
|
||||
@input="(newPassword) => password = newPassword"
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="password-container">
|
||||
<input
|
||||
v-model="passwordConfirm"
|
||||
:type="showPassword ? 'input' : 'password'"
|
||||
autocomplete="new-password"
|
||||
minlength="3"
|
||||
placeholder="Confirm password"
|
||||
class="password input"
|
||||
required
|
||||
>
|
||||
|
||||
<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>
|
||||
<Password
|
||||
:password="passwordConfirm"
|
||||
autocomplete="new-password"
|
||||
placeholder="Confirm password"
|
||||
@input="(newPassword) => passwordConfirm = newPassword"
|
||||
/>
|
||||
|
||||
<VueHCaptcha
|
||||
v-if="env.captcha.enabled"
|
||||
@@ -193,6 +149,10 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
|
||||
@@ -9,6 +9,7 @@ import Summaries from '#/components/scenes/summaries.vue';
|
||||
// filters are implemented as generic settings panel in case we want to extend it in the future
|
||||
import Settings from '#/components/settings/settings.vue';
|
||||
import Stashes from '#/components/stashes/stashes.vue';
|
||||
import Account from '#/components/user/account.vue';
|
||||
import ApiKeys from '#/components/user/api-keys.vue';
|
||||
import Preferences from '#/components/user/preferences.vue';
|
||||
|
||||
@@ -19,6 +20,7 @@ const domain = pageContext.routeParams.domain;
|
||||
|
||||
const user = pageContext.user;
|
||||
const profile = ref(pageContext.pageProps.profile);
|
||||
const primaryStash = pageContext.assets.primaryStash;
|
||||
|
||||
const showFilters = ref(false);
|
||||
|
||||
@@ -70,6 +72,12 @@ function scrollHorizontal(event) {
|
||||
class="domains nobar"
|
||||
@wheel.prevent="scrollHorizontal"
|
||||
>
|
||||
<a
|
||||
v-if="primaryStash"
|
||||
:href="`/stash/${user.username}/${primaryStash.slug}`"
|
||||
class="domain nolink"
|
||||
><Icon :icon="primaryStash.slug === 'favorites' ? 'heart7' : 'folder-heart'" />{{ primaryStash.name }}</a>
|
||||
|
||||
<a
|
||||
:href="`/user/${profile.username}/stashes`"
|
||||
class="domain nolink"
|
||||
@@ -88,13 +96,6 @@ function scrollHorizontal(event) {
|
||||
:class="{ active: section === 'templates' }"
|
||||
><Icon icon="paste3" />Templates</a>
|
||||
|
||||
<a
|
||||
v-if="profile.isIdentityVerified"
|
||||
:href="`/user/${profile.username}/api`"
|
||||
class="domain nolink"
|
||||
:class="{ active: section === 'api' }"
|
||||
><Icon icon="key" />API Keys</a>
|
||||
|
||||
<a
|
||||
:href="`/user/${profile.username}/revisions/scenes`"
|
||||
class="domain nolink"
|
||||
@@ -114,11 +115,24 @@ function scrollHorizontal(event) {
|
||||
@click="showFilters = true"
|
||||
><Icon icon="filter" />Filters</span>
|
||||
|
||||
<a
|
||||
v-if="profile.isIdentityVerified"
|
||||
:href="`/user/${profile.username}/api`"
|
||||
class="domain nolink"
|
||||
:class="{ active: section === 'api' }"
|
||||
><Icon icon="key" />API Keys</a>
|
||||
|
||||
<a
|
||||
:href="`/user/${profile.username}/preferences`"
|
||||
class="domain nolink"
|
||||
:class="{ active: section === 'preferences' }"
|
||||
><Icon icon="equalizer" />Preferences</a>
|
||||
|
||||
<a
|
||||
:href="`/user/${profile.username}/account`"
|
||||
class="domain nolink"
|
||||
:class="{ active: section === 'account' }"
|
||||
><Icon icon="user-tie" />Account</a>
|
||||
</nav>
|
||||
|
||||
<Stashes v-if="section === 'stashes'" />
|
||||
@@ -129,13 +143,9 @@ function scrollHorizontal(event) {
|
||||
:release="mockupRelease"
|
||||
/>
|
||||
|
||||
<ApiKeys
|
||||
v-if="section === 'api'"
|
||||
/>
|
||||
|
||||
<Preferences
|
||||
v-if="section === 'preferences'"
|
||||
/>
|
||||
<ApiKeys v-if="section === 'api'" />
|
||||
<Preferences v-if="section === 'preferences'" />
|
||||
<Account v-if="section === 'account'" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
85
pages/verify/email/+Page.vue
Normal file
85
pages/verify/email/+Page.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<script setup>
|
||||
import { inject, ref } from 'vue';
|
||||
|
||||
import Ellipsis from '#/components/loading/ellipsis.vue';
|
||||
|
||||
import { post } from '#/src/api.js';
|
||||
|
||||
const { urlParsed } = inject('pageContext');
|
||||
const { userId, token } = urlParsed.search;
|
||||
|
||||
const errorMsg = ref(null);
|
||||
const submitting = ref(false);
|
||||
const success = ref(false);
|
||||
|
||||
async function verify() {
|
||||
errorMsg.value = null;
|
||||
submitting.value = true;
|
||||
|
||||
try {
|
||||
await post('/emails/verify', {
|
||||
userId,
|
||||
token,
|
||||
}, {
|
||||
appendErrorMessage: true,
|
||||
});
|
||||
|
||||
success.value = true;
|
||||
}
|
||||
catch (error) {
|
||||
errorMsg.value = error.message;
|
||||
}
|
||||
finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="content">
|
||||
<Ellipsis v-if="submitting" />
|
||||
|
||||
<strong
|
||||
v-else-if="errorMsg"
|
||||
class="feedback error"
|
||||
>{{ errorMsg }}</strong>
|
||||
|
||||
<strong
|
||||
v-else-if="success"
|
||||
class="feedback success"
|
||||
>Verification successful</strong>
|
||||
|
||||
<button
|
||||
v-else
|
||||
class="button button-primary"
|
||||
@click="verify"
|
||||
>Click here to verify your e-mail address</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-grow: 1;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 1rem 1.5rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.feedback {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.success {
|
||||
color: var(--success);
|
||||
}
|
||||
</style>
|
||||
@@ -3,11 +3,10 @@ import { CronJob } from 'cron';
|
||||
import { add } from 'date-fns';
|
||||
import escapeRegexp from 'escape-string-regexp';
|
||||
import markdownIt from 'markdown-it';
|
||||
import { Resend } from 'resend';
|
||||
|
||||
import promiseProps from '../utils/promise-props.js';
|
||||
import { getIdsBySlug } from './cache.js';
|
||||
import { emailTemplates } from './email-templates.js';
|
||||
import { sendEmails } from './email.js';
|
||||
import { HttpError } from './errors.js';
|
||||
import { knexOwner as knex } from './knex.js';
|
||||
import initLogger from './logger.js';
|
||||
@@ -15,18 +14,6 @@ import { indexApi } from './manticore.js';
|
||||
import { fetchScenesById } from './scenes.js';
|
||||
|
||||
const logger = initLogger();
|
||||
const resend = (() => {
|
||||
if (config.email.enabled) {
|
||||
try {
|
||||
return new Resend(config.email.apiKey);
|
||||
}
|
||||
catch (error) {
|
||||
logger.error(`Failed to initialize Resend: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
})();
|
||||
|
||||
function getImagePath(media) {
|
||||
if (!media) {
|
||||
@@ -452,11 +439,6 @@ async function sendEmailAlerts() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resend) {
|
||||
logger.warn('E-mails are enabled, but Resend is not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
const qualifyingUsers = await knex('notifications')
|
||||
.select('users.id as user_id', 'users.settings', knex.raw('max(emailed_at) as last_email'))
|
||||
.where('notifications.email', true)
|
||||
@@ -546,7 +528,7 @@ async function sendEmailAlerts() {
|
||||
&& !!notification.scene
|
||||
&& notification.user.settings?.alertEmailsEnabled !== false); // keep the e-mails marked as 'sent', if emails get re-enabled there shouldn't be a barrage of old notifications
|
||||
|
||||
const emails = await Promise.all(Object.values(usersByUserId).map(async (user) => {
|
||||
const emailItems = Object.values(usersByUserId).map((user) => {
|
||||
const userTriggers = Object.values(Object.fromEntries(triggers
|
||||
.filter((trigger) => trigger.user.id === user.id)
|
||||
.map((trigger) => [trigger.scene.id, trigger])))
|
||||
@@ -556,42 +538,24 @@ async function sendEmailAlerts() {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { html, text } = await emailTemplates.render('alert.vue', {
|
||||
props: {
|
||||
user,
|
||||
triggers: userTriggers,
|
||||
address: config.web.address,
|
||||
},
|
||||
});
|
||||
return {
|
||||
template: 'alert',
|
||||
to: user.email,
|
||||
subject: `Notification for ${userTriggers.length} scenes on traxxx`,
|
||||
props: {
|
||||
user,
|
||||
triggers: userTriggers,
|
||||
address: config.web.address,
|
||||
},
|
||||
};
|
||||
}).filter(Boolean);
|
||||
|
||||
return {
|
||||
from: config.email.from,
|
||||
to: user.email,
|
||||
subject: `Notification for ${userTriggers.length} scenes on traxxx`,
|
||||
html,
|
||||
text,
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
logger.warn(`Failed to render e-mail for '${user.username}' with scenes ${userTriggers.map((trigger) => `${trigger.notificationId}: ${trigger.scene.id}`)}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
})).then((allEmails) => allEmails.filter(Boolean));
|
||||
|
||||
if (emails.length === 0) {
|
||||
if (emailItems.length === 0) {
|
||||
logger.info(`No alert e-mails sent`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, error } = await resend.batch.send(emails);
|
||||
|
||||
if (error) {
|
||||
logger.error(`Failed to send ${emails.length} alert e-mails (${error.statusCode} ${error.name}): ${error.message}`);
|
||||
}
|
||||
else {
|
||||
logger.info(`Sent ${data.data.length}/${emails.length} alert e-mails`);
|
||||
}
|
||||
await sendEmails(emailItems);
|
||||
}
|
||||
|
||||
export async function notify(sceneIds, options = {}) {
|
||||
|
||||
217
src/auth.js
217
src/auth.js
@@ -1,3 +1,4 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import util from 'node:util';
|
||||
@@ -8,6 +9,7 @@ import config from 'config';
|
||||
import { verify } from 'hcaptcha';
|
||||
|
||||
import slugify from '../utils/slugify.js';
|
||||
import { sendEmails } from './email.js';
|
||||
import { HttpError } from './errors.js';
|
||||
import { knexOwner as knex } from './knex.js';
|
||||
import initLogger, { initAccessLogger } from './logger.js';
|
||||
@@ -46,7 +48,7 @@ async function generateAvatar(user) {
|
||||
|
||||
export async function login(credentials, userIp) {
|
||||
if (!config.auth.login) {
|
||||
throw new HttpError('Logins are currently disabled', 405);
|
||||
throw new HttpError('Logins are currently disabled', 503);
|
||||
}
|
||||
|
||||
const { user } = await fetchUser(credentials.username.trim(), {
|
||||
@@ -76,9 +78,62 @@ export async function login(credentials, userIp) {
|
||||
return curateUser(user, user);
|
||||
}
|
||||
|
||||
async function hashPassword(password) {
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hashedPassword = (await scrypt(password, salt, 64)).toString('hex');
|
||||
const storedPassword = `${salt}/${hashedPassword}`;
|
||||
|
||||
return {
|
||||
storedPassword,
|
||||
hashedPassword,
|
||||
salt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendEmailVerification(user, customEmail, trigger) {
|
||||
if (!user) {
|
||||
throw new HttpError('No user provided', 401);
|
||||
}
|
||||
|
||||
const email = customEmail || user.email;
|
||||
|
||||
if (!email) {
|
||||
throw new HttpError('No e-mail address provided', 400);
|
||||
}
|
||||
|
||||
const tokenKey = `traxxx:token_email:${user.id}`;
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
const tokenData = {
|
||||
email,
|
||||
token,
|
||||
};
|
||||
|
||||
await redis.set(tokenKey, JSON.stringify(tokenData));
|
||||
await redis.expire(tokenKey, config.auth.emailTokenExpiry * 60);
|
||||
|
||||
const verifyUrl = `${config.web.address}/verify/email?${new URLSearchParams({
|
||||
userId: user.id,
|
||||
token,
|
||||
}).toString()}`;
|
||||
|
||||
logger.info(`E-mail verification sent to ${user.username} (${user.id}): ${verifyUrl}`);
|
||||
|
||||
await sendEmails([{
|
||||
template: 'verify',
|
||||
to: email,
|
||||
subject: 'Please verify your traxxx e-mail address',
|
||||
props: {
|
||||
user,
|
||||
verifyUrl,
|
||||
trigger,
|
||||
},
|
||||
}]);
|
||||
}
|
||||
|
||||
export async function signup(credentials, userIp) {
|
||||
if (!config.auth.signup) {
|
||||
throw new HttpError('Sign-ups are currently disabled', 405);
|
||||
throw new HttpError('Sign-ups are currently disabled', 503);
|
||||
}
|
||||
|
||||
const curatedUsername = credentials.username.trim();
|
||||
@@ -125,9 +180,7 @@ export async function signup(credentials, userIp) {
|
||||
throw new HttpError('Username or e-mail already in use', 409);
|
||||
}
|
||||
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hashedPassword = (await scrypt(credentials.password, salt, 64)).toString('hex');
|
||||
const storedPassword = `${salt}/${hashedPassword}`;
|
||||
const { storedPassword } = await hashPassword(credentials.password);
|
||||
|
||||
const [{ id: userId }] = await knex('users')
|
||||
.insert({
|
||||
@@ -152,7 +205,159 @@ export async function signup(credentials, userIp) {
|
||||
username: curatedUsername,
|
||||
});
|
||||
|
||||
return fetchUser(userId);
|
||||
const user = await fetchUser(userId, {
|
||||
includePrivate: true,
|
||||
});
|
||||
|
||||
await sendEmailVerification(user, null, 'signup');
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async function updatePassword(password, reqUser, reqSession) {
|
||||
const now = new Date();
|
||||
const { storedPassword } = await hashPassword(password);
|
||||
|
||||
await knex('users')
|
||||
.where('id', reqUser.id)
|
||||
.update('password', storedPassword);
|
||||
|
||||
const country = reqSession.country && await knex('countries').where('alpha2', reqSession.country).first();
|
||||
|
||||
await sendEmails([{
|
||||
template: 'password',
|
||||
to: reqUser.email,
|
||||
subject: 'Your traxxx password has been changed',
|
||||
props: {
|
||||
user: reqUser,
|
||||
ip: reqUser.ip,
|
||||
country: country?.name,
|
||||
address: config.web.address,
|
||||
timestamp: now,
|
||||
},
|
||||
}]);
|
||||
|
||||
logger.info(`Password changed by ${reqUser.username} (${reqUser.id}, ${reqUser.ip})`);
|
||||
}
|
||||
|
||||
async function updateEmail(email, reqUser) {
|
||||
const user = await fetchUser(email, {
|
||||
email: true,
|
||||
throwError: false,
|
||||
});
|
||||
|
||||
if (user) {
|
||||
throw new HttpError('This e-mail address is already in use', 409);
|
||||
}
|
||||
|
||||
await sendEmailVerification(reqUser, email, 'change');
|
||||
}
|
||||
|
||||
export async function verifyEmail(userId, emailToken) {
|
||||
if (!userId) {
|
||||
throw new HttpError('User ID not provided', 400);
|
||||
}
|
||||
|
||||
if (!emailToken) {
|
||||
throw new HttpError('Token not provided', 400);
|
||||
}
|
||||
|
||||
const tokenKey = `traxxx:token_email:${userId}`;
|
||||
const tokenEntry = await redis.getDel(tokenKey);
|
||||
|
||||
if (!tokenEntry) {
|
||||
throw new HttpError('Invalid verification link', 401);
|
||||
}
|
||||
|
||||
const { token, email } = JSON.parse(tokenEntry);
|
||||
|
||||
if (!token
|
||||
|| !email
|
||||
|| emailToken.length !== token.length
|
||||
|| !crypto.timingSafeEqual(Buffer.from(emailToken, 'hex'), Buffer.from(token, 'hex'))) {
|
||||
throw new HttpError('Invalid verification link', 401);
|
||||
}
|
||||
|
||||
const updated = await knex('users')
|
||||
.where('id', userId)
|
||||
.update({
|
||||
email,
|
||||
email_verified: true,
|
||||
});
|
||||
|
||||
if (updated === 0) {
|
||||
throw new HttpError('E-mail verification failed because the user could not be found', 404);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUser({ credentials, password }, reqUser, reqSession) {
|
||||
if (!reqUser) {
|
||||
throw new HttpError('You are not logged in', 401);
|
||||
}
|
||||
|
||||
if (!credentials?.email && !credentials.password) {
|
||||
throw new HttpError('No changeable credentials provided', 400);
|
||||
}
|
||||
|
||||
if (credentials.email && !config.email.enabled) {
|
||||
throw new HttpError('E-mail is currently disabled', 503);
|
||||
}
|
||||
|
||||
if (credentials.password && credentials.password?.length < 3) {
|
||||
throw new HttpError('Password must be 3 characters or longer', 400);
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
throw new HttpError('No current password provided', 401);
|
||||
}
|
||||
|
||||
const { user } = await fetchUser(reqUser.id, {
|
||||
email: true,
|
||||
raw: true,
|
||||
}).catch(() => {
|
||||
throw new HttpError('Username or password incorrect', 401);
|
||||
});
|
||||
|
||||
await verifyPassword(password, user.password);
|
||||
|
||||
if (credentials.password) {
|
||||
await updatePassword(credentials.password, reqUser, reqSession);
|
||||
}
|
||||
|
||||
if (credentials.email) {
|
||||
await updateEmail(credentials.email, reqUser);
|
||||
}
|
||||
|
||||
return curateUser(user, reqUser);
|
||||
}
|
||||
|
||||
const validSettingKeys = [
|
||||
'alertNotificationsEnabled',
|
||||
'alertEmailsEnabled',
|
||||
'alertEmailsFrequency',
|
||||
];
|
||||
|
||||
export async function updateSettings(settings, reqUser) {
|
||||
if (!reqUser) {
|
||||
throw new HttpError('You need to be authenticated to change your settings', 401);
|
||||
}
|
||||
|
||||
const invalidKeys = Object.keys(settings).filter((key) => !validSettingKeys.includes(key));
|
||||
|
||||
if (invalidKeys.length > 0) {
|
||||
throw new HttpError(`Invalid settings: ${invalidKeys.join()}`, 400);
|
||||
}
|
||||
|
||||
if (settings.alertEmailsFrequency && !['instant', 'daily', 'weekly', 'monthly'].includes(settings.alertEmailsFrequency)) {
|
||||
throw new HttpError('Invalid email frequency setting', 400);
|
||||
}
|
||||
|
||||
const [{ settings: updatedSettings }] = await knex('users')
|
||||
.where('id', reqUser.id)
|
||||
.update('settings', knex.raw('coalesce(settings, \'{}\'::jsonb) || ?::jsonb', [JSON.stringify(settings)]))
|
||||
.returning('settings');
|
||||
|
||||
return updatedSettings;
|
||||
}
|
||||
|
||||
function curateKey(key) {
|
||||
|
||||
79
src/email.js
Normal file
79
src/email.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import config from 'config';
|
||||
import { Resend } from 'resend';
|
||||
|
||||
import { emailTemplates } from './email-templates.js';
|
||||
import initLogger from './logger.js';
|
||||
|
||||
const logger = initLogger();
|
||||
|
||||
const resend = (() => {
|
||||
if (config.email.enabled) {
|
||||
try {
|
||||
return new Resend(config.email.apiKey);
|
||||
}
|
||||
catch (error) {
|
||||
logger.error(`Failed to initialize Resend: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
})();
|
||||
|
||||
export async function renderEmail(template, { to, subject, props }) {
|
||||
const { html, text } = await emailTemplates.render(`${template}.vue`, { props });
|
||||
|
||||
return {
|
||||
from: config.email.from,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
// always batches, even for a single item: Resend's batch endpoint accepts a batch of one,
|
||||
// and passing batchValidation: 'permissive' means one bad recipient among many doesn't
|
||||
// sink the rest of the batch (the default 'strict' mode fails the whole call instead)
|
||||
export async function sendEmails(items) {
|
||||
if (!config.email.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!resend) {
|
||||
logger.warn('E-mails are enabled, but Resend is not initialized');
|
||||
return null;
|
||||
}
|
||||
|
||||
const rendered = await Promise.all(items.map(async (item) => {
|
||||
try {
|
||||
return await renderEmail(item.template, item);
|
||||
}
|
||||
catch (error) {
|
||||
logger.error(`Failed to render '${item.template}' e-mail for '${item.to}': ${error.message} — ${JSON.stringify(item.props)}`);
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
|
||||
const sentItems = items.filter((item, index) => rendered[index]);
|
||||
const emails = rendered.filter(Boolean);
|
||||
|
||||
if (emails.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { data, error } = await resend.batch.send(emails, { batchValidation: 'permissive' });
|
||||
|
||||
if (error) {
|
||||
logger.error(`Failed to send ${emails.length} e-mail(s) (${error.statusCode} ${error.name}): ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
data.errors?.forEach(({ index, message }) => {
|
||||
const item = sentItems[index];
|
||||
logger.error(`Failed to send '${item.template}' e-mail to '${item.to}': ${message} — ${JSON.stringify(item.props)}`);
|
||||
});
|
||||
|
||||
logger.info(`Sent ${emails.length - (data.errors?.length || 0)}/${items.length} e-mail(s)`);
|
||||
|
||||
return data;
|
||||
}
|
||||
37
src/users.js
37
src/users.js
@@ -14,7 +14,7 @@ function curateTemplate(template) {
|
||||
};
|
||||
}
|
||||
|
||||
export function curateUser(user, reqUser) {
|
||||
export function curateUser(user, reqUser, options = {}) {
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export function curateUser(user, reqUser) {
|
||||
role: user.role,
|
||||
abilities: [...user.role_abilities || [], ...user.abilities || []],
|
||||
createdAt: user.created_at,
|
||||
...(reqUser?.id === user.id ? privateUser : null),
|
||||
...(reqUser?.id === user.id || options.includePrivate ? privateUser : null),
|
||||
};
|
||||
|
||||
return curatedUser;
|
||||
@@ -63,7 +63,7 @@ export async function fetchUser(userId, options = {}, reqUser) {
|
||||
.groupBy('users.id', 'users_roles.role')
|
||||
.first();
|
||||
|
||||
if (!user) {
|
||||
if (!user && options.throwError !== false) {
|
||||
throw new HttpError(`User '${userId}' not found`, 404);
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export async function fetchUser(userId, options = {}, reqUser) {
|
||||
return { user };
|
||||
}
|
||||
|
||||
return curateUser(user, reqUser);
|
||||
return curateUser(user, reqUser, options);
|
||||
}
|
||||
|
||||
export async function fetchUserTemplates(reqUser) {
|
||||
@@ -136,32 +136,3 @@ export async function createBan(ban, reqUser) {
|
||||
|
||||
await knex('bans').insert(curatedBan);
|
||||
}
|
||||
|
||||
const validSettingKeys = [
|
||||
'alertNotificationsEnabled',
|
||||
'alertEmailsEnabled',
|
||||
'alertEmailsFrequency',
|
||||
];
|
||||
|
||||
export async function updateSettings(settings, reqUser) {
|
||||
if (!reqUser) {
|
||||
throw new HttpError('You need to be authenticated to change your settings', 401);
|
||||
}
|
||||
|
||||
const invalidKeys = Object.keys(settings).filter((key) => !validSettingKeys.includes(key));
|
||||
|
||||
if (invalidKeys.length > 0) {
|
||||
throw new HttpError(`Invalid settings: ${invalidKeys.join()}`, 400);
|
||||
}
|
||||
|
||||
if (settings.alertEmailsFrequency && !['instant', 'daily', 'weekly', 'monthly'].includes(settings.alertEmailsFrequency)) {
|
||||
throw new HttpError('Invalid email frequency setting', 400);
|
||||
}
|
||||
|
||||
const [{ settings: updatedSettings }] = await knex('users')
|
||||
.where('id', reqUser.id)
|
||||
.update('settings', knex.raw('coalesce(settings, \'{}\'::jsonb) || ?::jsonb', [JSON.stringify(settings)]))
|
||||
.returning('settings');
|
||||
|
||||
return updatedSettings;
|
||||
}
|
||||
|
||||
@@ -219,12 +219,12 @@ export async function updateNotificationGraphql(query, req) {
|
||||
return updatedNotification;
|
||||
}
|
||||
|
||||
export const router = Router();
|
||||
export const alertsRouter = Router();
|
||||
|
||||
router.get('/api/alerts', fetchAlertsApi);
|
||||
router.post('/api/alerts', createAlertApi);
|
||||
router.delete('/api/alerts/:alertId', removeAlertApi);
|
||||
alertsRouter.get('/api/alerts', fetchAlertsApi);
|
||||
alertsRouter.post('/api/alerts', createAlertApi);
|
||||
alertsRouter.delete('/api/alerts/:alertId', removeAlertApi);
|
||||
|
||||
router.get('/api/users/:userId/notifications', fetchNotificationsApi);
|
||||
router.patch('/api/users/:userId/notifications', updateNotificationsApi);
|
||||
router.patch('/api/users/:userId/notifications/:notificationId', updateNotificationApi);
|
||||
alertsRouter.get('/api/users/:userId/notifications', fetchNotificationsApi);
|
||||
alertsRouter.patch('/api/users/:userId/notifications', updateNotificationsApi);
|
||||
alertsRouter.patch('/api/users/:userId/notifications/:notificationId', updateNotificationApi);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { Router } from 'express';
|
||||
import IPCIDR from 'ip-cidr';
|
||||
import argv from '../argv.js';
|
||||
|
||||
@@ -8,10 +9,16 @@ import {
|
||||
flushUserKeys,
|
||||
login,
|
||||
removeUserKey,
|
||||
sendEmailVerification,
|
||||
signup,
|
||||
updateSettings,
|
||||
updateUser,
|
||||
verifyEmail,
|
||||
} from '../auth.js';
|
||||
|
||||
import { fetchUser } from '../users.js';
|
||||
import {
|
||||
fetchUser,
|
||||
} from '../users.js';
|
||||
|
||||
// changes every time the process restarts, so a deploy invalidates all cached session users
|
||||
// even if they're within the staleness window below
|
||||
@@ -131,6 +138,60 @@ export async function signupApi(req, res) {
|
||||
res.send(user);
|
||||
}
|
||||
|
||||
async function updateUserApi(req, res) {
|
||||
const updatedUser = await updateUser(req.body, req.user, req.session);
|
||||
const sessions = await req.sessionStore.all();
|
||||
|
||||
req.session.user.refreshedAt = 0;
|
||||
|
||||
await Promise.all(sessions.map(async (session) => {
|
||||
if (session.user?.id === req.user.id && session.id !== req.sessionID) {
|
||||
if (req.body.credentials?.password) {
|
||||
// we changed the password, destroy all user sessions except our own (we already provided all the credentials)
|
||||
await req.sessionStore.destroy(session.id);
|
||||
}
|
||||
else {
|
||||
// mark our sessions as stale
|
||||
session.user.refreshedAt = 0; // eslint-disable-line no-param-reassign
|
||||
await req.sessionStore.set(session.id, session);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
res.send(updatedUser);
|
||||
}
|
||||
|
||||
async function sendEmailVerificationApi(req, res) {
|
||||
await sendEmailVerification(req.user, null, 'verify');
|
||||
|
||||
res.status(204).send();
|
||||
}
|
||||
|
||||
async function verifyEmailApi(req, res) {
|
||||
const userId = Number(req.body.userId) || null;
|
||||
|
||||
await verifyEmail(userId, req.body.token);
|
||||
|
||||
const sessions = await req.sessionStore.all();
|
||||
|
||||
await Promise.all(sessions.map(async (session) => {
|
||||
if (session.user?.id === userId) {
|
||||
session.user.refreshedAt = 0; // eslint-disable-line no-param-reassign
|
||||
await req.sessionStore.set(session.id, session);
|
||||
}
|
||||
}));
|
||||
|
||||
res.status(204).send();
|
||||
}
|
||||
|
||||
async function updateSettingsApi(req, res) {
|
||||
const updatedSettings = await updateSettings(req.body, req.user);
|
||||
|
||||
req.session.user.settings = updatedSettings;
|
||||
|
||||
res.send(updatedSettings);
|
||||
}
|
||||
|
||||
export async function fetchUserKeysApi(req, res) {
|
||||
const keys = await fetchUserKeys(req.user);
|
||||
|
||||
@@ -154,3 +215,28 @@ export async function flushUserKeysApi(req, res) {
|
||||
|
||||
res.status(204).send();
|
||||
}
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
// SESSION
|
||||
// authRouter.use(setUserApi);
|
||||
|
||||
authRouter.post('/api/session', loginApi);
|
||||
authRouter.delete('/api/session', logoutApi);
|
||||
|
||||
// USERS
|
||||
authRouter.post('/api/users', signupApi);
|
||||
|
||||
// SETTINGS
|
||||
authRouter.patch('/api/me', updateUserApi);
|
||||
authRouter.patch('/api/me/settings', updateSettingsApi);
|
||||
|
||||
// EMAIL VERIFICATION
|
||||
authRouter.post('/api/me/email_verification', sendEmailVerificationApi);
|
||||
authRouter.post('/api/emails/verify', verifyEmailApi); // usable without session
|
||||
|
||||
// API KEYS
|
||||
authRouter.get('/api/me/keys', fetchUserKeysApi);
|
||||
authRouter.post('/api/me/keys', createKeyApi);
|
||||
authRouter.delete('/api/me/keys/:keyIdentifier', removeUserKeyApi);
|
||||
authRouter.delete('/api/me/keys', flushUserKeysApi);
|
||||
|
||||
@@ -30,6 +30,7 @@ export default async function mainHandler(req, res, next) {
|
||||
role: req.user.role,
|
||||
abilities: req.user.abilities,
|
||||
avatar: req.user.avatar,
|
||||
isEmailVerified: req.user.isEmailVerified,
|
||||
},
|
||||
assets: req.user
|
||||
? {
|
||||
|
||||
@@ -12,19 +12,11 @@ import initLogger from '../logger.js';
|
||||
|
||||
import redis from '../redis.js';
|
||||
import queryBoolean from '../utils/query-boolean.js';
|
||||
import { actorsRouter } from './actors.js';
|
||||
|
||||
import { router as alertsRouter } from './alerts.js';
|
||||
import {
|
||||
createKeyApi,
|
||||
fetchUserKeysApi,
|
||||
flushUserKeysApi,
|
||||
loginApi,
|
||||
logoutApi,
|
||||
removeUserKeyApi,
|
||||
setUserApi,
|
||||
signupApi,
|
||||
} from './auth.js';
|
||||
import { actorsRouter } from './actors.js';
|
||||
import { alertsRouter } from './alerts.js';
|
||||
import { authRouter, setUserApi } from './auth.js';
|
||||
|
||||
import consentHandler from './consent.js';
|
||||
|
||||
import { fetchEntitiesApi } from './entities.js';
|
||||
@@ -91,8 +83,6 @@ export default async function initServer() {
|
||||
store: redisStore,
|
||||
}));
|
||||
|
||||
router.use(setUserApi);
|
||||
|
||||
// Vite integration
|
||||
if (isProduction) {
|
||||
app.enable('trust proxy');
|
||||
@@ -118,6 +108,7 @@ export default async function initServer() {
|
||||
router.use(viteDevMiddleware);
|
||||
}
|
||||
|
||||
router.use(setUserApi); // we need to set the IP before the restriction handler
|
||||
router.use(restrictionHandler);
|
||||
|
||||
router.get('/consent', (_req, res) => {
|
||||
@@ -134,22 +125,10 @@ export default async function initServer() {
|
||||
next();
|
||||
});
|
||||
|
||||
// SESSION
|
||||
router.post('/api/session', loginApi);
|
||||
router.delete('/api/session', logoutApi);
|
||||
|
||||
// USERS
|
||||
router.post('/api/users', signupApi);
|
||||
|
||||
// API KEYS
|
||||
router.get('/api/me/keys', fetchUserKeysApi);
|
||||
router.post('/api/me/keys', createKeyApi);
|
||||
router.delete('/api/me/keys/:keyIdentifier', removeUserKeyApi);
|
||||
router.delete('/api/me/keys', flushUserKeysApi);
|
||||
|
||||
router.use(userRouter);
|
||||
router.use(stashesRouter);
|
||||
router.use(alertsRouter);
|
||||
router.use(authRouter);
|
||||
router.use(scenesRouter);
|
||||
router.use(actorsRouter);
|
||||
router.use(syncRouter);
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
fetchUser,
|
||||
fetchUserTemplates,
|
||||
removeTemplate,
|
||||
updateSettings,
|
||||
} from '../users.js';
|
||||
|
||||
async function fetchUserApi(req, res) {
|
||||
@@ -15,14 +14,6 @@ async function fetchUserApi(req, res) {
|
||||
res.send(user);
|
||||
}
|
||||
|
||||
async function updateSettingsApi(req, res) {
|
||||
const updatedSettings = await updateSettings(req.body, req.user);
|
||||
|
||||
req.session.user.settings = updatedSettings;
|
||||
|
||||
res.send(updatedSettings);
|
||||
}
|
||||
|
||||
async function fetchUserTemplatesApi(req, res) {
|
||||
const templates = await fetchUserTemplates(req.user);
|
||||
|
||||
@@ -52,8 +43,6 @@ export const router = Router();
|
||||
router.get('/api/users/:userId', fetchUserApi);
|
||||
router.get('/api/users/:userId/templates', fetchUserTemplatesApi);
|
||||
|
||||
router.patch('/api/me/settings', updateSettingsApi);
|
||||
|
||||
router.post('/api/templates', createTemplateApi);
|
||||
router.delete('/api/templates/:templateId', removeTemplateApi);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user