Added account update form and e-mail verification.
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
.input {
|
.input {
|
||||||
|
width: 15rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: .5rem .75rem;
|
padding: .5rem .75rem;
|
||||||
font-size: 1rem;
|
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',
|
siteKey: '10000000-ffff-ffff-ffff-000000000001',
|
||||||
secretKey: '0x0000000000000000000000000000000000000000',
|
secretKey: '0x0000000000000000000000000000000000000000',
|
||||||
},
|
},
|
||||||
|
emailTokenExpiry: 120, // minutes
|
||||||
},
|
},
|
||||||
bans: {
|
bans: {
|
||||||
defaultExpiry: 60 * 24 * 3, // in minutes, 3 days
|
defaultExpiry: 60 * 24 * 3, // in minutes, 3 days
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { inject, onMounted, ref } from 'vue';
|
import { inject, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
import Password from '#/components/form/password.vue';
|
||||||
|
|
||||||
import { post } from '#/src/api.js';
|
import { post } from '#/src/api.js';
|
||||||
import navigate from '#/src/navigate.js';
|
import navigate from '#/src/navigate.js';
|
||||||
|
|
||||||
@@ -14,7 +16,6 @@ const password = ref('');
|
|||||||
const submitted = ref(false);
|
const submitted = ref(false);
|
||||||
const errorMsg = ref(null);
|
const errorMsg = ref(null);
|
||||||
const userInput = ref(null);
|
const userInput = ref(null);
|
||||||
const showPassword = ref(false);
|
|
||||||
|
|
||||||
async function login() {
|
async function login() {
|
||||||
submitted.value = true;
|
submitted.value = true;
|
||||||
@@ -88,32 +89,11 @@ onMounted(() => {
|
|||||||
class="input"
|
class="input"
|
||||||
>
|
>
|
||||||
|
|
||||||
<div class="password-container">
|
<Password
|
||||||
<input
|
:password="password"
|
||||||
v-model="password"
|
@input="(newPassword) => password = newPassword"
|
||||||
: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>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="button button-submit"
|
class="button button-submit"
|
||||||
:disabled="submitted"
|
:disabled="submitted"
|
||||||
@@ -129,6 +109,10 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.login-container {
|
.login-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import VueHCaptcha from '@hcaptcha/vue3-hcaptcha';
|
import VueHCaptcha from '@hcaptcha/vue3-hcaptcha';
|
||||||
import { inject, onMounted, ref } from 'vue';
|
import { inject, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
import Password from '#/components/form/password.vue';
|
||||||
|
|
||||||
import { post } from '#/src/api.js';
|
import { post } from '#/src/api.js';
|
||||||
import navigate from '#/src/navigate.js';
|
import navigate from '#/src/navigate.js';
|
||||||
|
|
||||||
@@ -16,7 +18,6 @@ const passwordConfirm = ref('');
|
|||||||
const errorMsg = ref(null);
|
const errorMsg = ref(null);
|
||||||
const submitted = ref(false);
|
const submitted = ref(false);
|
||||||
const userInput = ref(null);
|
const userInput = ref(null);
|
||||||
const showPassword = ref(false);
|
|
||||||
const captcha = ref(null);
|
const captcha = ref(null);
|
||||||
|
|
||||||
async function signup() {
|
async function signup() {
|
||||||
@@ -113,64 +114,19 @@ onMounted(() => {
|
|||||||
required
|
required
|
||||||
>
|
>
|
||||||
|
|
||||||
<div class="password-container">
|
<Password
|
||||||
<input
|
:password="password"
|
||||||
v-model="password"
|
|
||||||
:type="showPassword ? 'input' : 'password'"
|
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
minlength="3"
|
@input="(newPassword) => password = newPassword"
|
||||||
placeholder="Password"
|
|
||||||
class="password input"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="password-show"
|
|
||||||
@click="showPassword = !showPassword"
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
v-show="!showPassword"
|
|
||||||
icon="eye"
|
|
||||||
class="password-show"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Icon
|
<Password
|
||||||
v-show="showPassword"
|
:password="passwordConfirm"
|
||||||
icon="eye-blocked"
|
|
||||||
class="password-show"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="password-container">
|
|
||||||
<input
|
|
||||||
v-model="passwordConfirm"
|
|
||||||
:type="showPassword ? 'input' : 'password'"
|
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
minlength="3"
|
|
||||||
placeholder="Confirm password"
|
placeholder="Confirm password"
|
||||||
class="password input"
|
@input="(newPassword) => passwordConfirm = newPassword"
|
||||||
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>
|
|
||||||
|
|
||||||
<VueHCaptcha
|
<VueHCaptcha
|
||||||
v-if="env.captcha.enabled"
|
v-if="env.captcha.enabled"
|
||||||
:sitekey="env.captcha.siteKey"
|
:sitekey="env.captcha.siteKey"
|
||||||
@@ -193,6 +149,10 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.login-container {
|
.login-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-grow: 1;
|
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
|
// 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 Settings from '#/components/settings/settings.vue';
|
||||||
import Stashes from '#/components/stashes/stashes.vue';
|
import Stashes from '#/components/stashes/stashes.vue';
|
||||||
|
import Account from '#/components/user/account.vue';
|
||||||
import ApiKeys from '#/components/user/api-keys.vue';
|
import ApiKeys from '#/components/user/api-keys.vue';
|
||||||
import Preferences from '#/components/user/preferences.vue';
|
import Preferences from '#/components/user/preferences.vue';
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ const domain = pageContext.routeParams.domain;
|
|||||||
|
|
||||||
const user = pageContext.user;
|
const user = pageContext.user;
|
||||||
const profile = ref(pageContext.pageProps.profile);
|
const profile = ref(pageContext.pageProps.profile);
|
||||||
|
const primaryStash = pageContext.assets.primaryStash;
|
||||||
|
|
||||||
const showFilters = ref(false);
|
const showFilters = ref(false);
|
||||||
|
|
||||||
@@ -70,6 +72,12 @@ function scrollHorizontal(event) {
|
|||||||
class="domains nobar"
|
class="domains nobar"
|
||||||
@wheel.prevent="scrollHorizontal"
|
@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
|
<a
|
||||||
:href="`/user/${profile.username}/stashes`"
|
:href="`/user/${profile.username}/stashes`"
|
||||||
class="domain nolink"
|
class="domain nolink"
|
||||||
@@ -88,13 +96,6 @@ function scrollHorizontal(event) {
|
|||||||
:class="{ active: section === 'templates' }"
|
:class="{ active: section === 'templates' }"
|
||||||
><Icon icon="paste3" />Templates</a>
|
><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
|
<a
|
||||||
:href="`/user/${profile.username}/revisions/scenes`"
|
:href="`/user/${profile.username}/revisions/scenes`"
|
||||||
class="domain nolink"
|
class="domain nolink"
|
||||||
@@ -114,11 +115,24 @@ function scrollHorizontal(event) {
|
|||||||
@click="showFilters = true"
|
@click="showFilters = true"
|
||||||
><Icon icon="filter" />Filters</span>
|
><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
|
<a
|
||||||
:href="`/user/${profile.username}/preferences`"
|
:href="`/user/${profile.username}/preferences`"
|
||||||
class="domain nolink"
|
class="domain nolink"
|
||||||
:class="{ active: section === 'preferences' }"
|
:class="{ active: section === 'preferences' }"
|
||||||
><Icon icon="equalizer" />Preferences</a>
|
><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>
|
</nav>
|
||||||
|
|
||||||
<Stashes v-if="section === 'stashes'" />
|
<Stashes v-if="section === 'stashes'" />
|
||||||
@@ -129,13 +143,9 @@ function scrollHorizontal(event) {
|
|||||||
:release="mockupRelease"
|
:release="mockupRelease"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ApiKeys
|
<ApiKeys v-if="section === 'api'" />
|
||||||
v-if="section === 'api'"
|
<Preferences v-if="section === 'preferences'" />
|
||||||
/>
|
<Account v-if="section === 'account'" />
|
||||||
|
|
||||||
<Preferences
|
|
||||||
v-if="section === 'preferences'"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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 { add } from 'date-fns';
|
||||||
import escapeRegexp from 'escape-string-regexp';
|
import escapeRegexp from 'escape-string-regexp';
|
||||||
import markdownIt from 'markdown-it';
|
import markdownIt from 'markdown-it';
|
||||||
import { Resend } from 'resend';
|
|
||||||
|
|
||||||
import promiseProps from '../utils/promise-props.js';
|
import promiseProps from '../utils/promise-props.js';
|
||||||
import { getIdsBySlug } from './cache.js';
|
import { getIdsBySlug } from './cache.js';
|
||||||
import { emailTemplates } from './email-templates.js';
|
import { sendEmails } from './email.js';
|
||||||
import { HttpError } from './errors.js';
|
import { HttpError } from './errors.js';
|
||||||
import { knexOwner as knex } from './knex.js';
|
import { knexOwner as knex } from './knex.js';
|
||||||
import initLogger from './logger.js';
|
import initLogger from './logger.js';
|
||||||
@@ -15,18 +14,6 @@ import { indexApi } from './manticore.js';
|
|||||||
import { fetchScenesById } from './scenes.js';
|
import { fetchScenesById } from './scenes.js';
|
||||||
|
|
||||||
const logger = initLogger();
|
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) {
|
function getImagePath(media) {
|
||||||
if (!media) {
|
if (!media) {
|
||||||
@@ -452,11 +439,6 @@ async function sendEmailAlerts() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!resend) {
|
|
||||||
logger.warn('E-mails are enabled, but Resend is not initialized');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const qualifyingUsers = await knex('notifications')
|
const qualifyingUsers = await knex('notifications')
|
||||||
.select('users.id as user_id', 'users.settings', knex.raw('max(emailed_at) as last_email'))
|
.select('users.id as user_id', 'users.settings', knex.raw('max(emailed_at) as last_email'))
|
||||||
.where('notifications.email', true)
|
.where('notifications.email', true)
|
||||||
@@ -546,7 +528,7 @@ async function sendEmailAlerts() {
|
|||||||
&& !!notification.scene
|
&& !!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
|
&& 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
|
const userTriggers = Object.values(Object.fromEntries(triggers
|
||||||
.filter((trigger) => trigger.user.id === user.id)
|
.filter((trigger) => trigger.user.id === user.id)
|
||||||
.map((trigger) => [trigger.scene.id, trigger])))
|
.map((trigger) => [trigger.scene.id, trigger])))
|
||||||
@@ -556,42 +538,24 @@ async function sendEmailAlerts() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return {
|
||||||
const { html, text } = await emailTemplates.render('alert.vue', {
|
template: 'alert',
|
||||||
|
to: user.email,
|
||||||
|
subject: `Notification for ${userTriggers.length} scenes on traxxx`,
|
||||||
props: {
|
props: {
|
||||||
user,
|
user,
|
||||||
triggers: userTriggers,
|
triggers: userTriggers,
|
||||||
address: config.web.address,
|
address: config.web.address,
|
||||||
},
|
},
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
from: config.email.from,
|
|
||||||
to: user.email,
|
|
||||||
subject: `Notification for ${userTriggers.length} scenes on traxxx`,
|
|
||||||
html,
|
|
||||||
text,
|
|
||||||
};
|
};
|
||||||
}
|
}).filter(Boolean);
|
||||||
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`);
|
logger.info(`No alert e-mails sent`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, error } = await resend.batch.send(emails);
|
await sendEmails(emailItems);
|
||||||
|
|
||||||
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`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function notify(sceneIds, options = {}) {
|
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 crypto from 'node:crypto';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import util from 'node:util';
|
import util from 'node:util';
|
||||||
@@ -8,6 +9,7 @@ import config from 'config';
|
|||||||
import { verify } from 'hcaptcha';
|
import { verify } from 'hcaptcha';
|
||||||
|
|
||||||
import slugify from '../utils/slugify.js';
|
import slugify from '../utils/slugify.js';
|
||||||
|
import { sendEmails } from './email.js';
|
||||||
import { HttpError } from './errors.js';
|
import { HttpError } from './errors.js';
|
||||||
import { knexOwner as knex } from './knex.js';
|
import { knexOwner as knex } from './knex.js';
|
||||||
import initLogger, { initAccessLogger } from './logger.js';
|
import initLogger, { initAccessLogger } from './logger.js';
|
||||||
@@ -46,7 +48,7 @@ async function generateAvatar(user) {
|
|||||||
|
|
||||||
export async function login(credentials, userIp) {
|
export async function login(credentials, userIp) {
|
||||||
if (!config.auth.login) {
|
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(), {
|
const { user } = await fetchUser(credentials.username.trim(), {
|
||||||
@@ -76,9 +78,62 @@ export async function login(credentials, userIp) {
|
|||||||
return curateUser(user, user);
|
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) {
|
export async function signup(credentials, userIp) {
|
||||||
if (!config.auth.signup) {
|
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();
|
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);
|
throw new HttpError('Username or e-mail already in use', 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
const salt = crypto.randomBytes(16).toString('hex');
|
const { storedPassword } = await hashPassword(credentials.password);
|
||||||
const hashedPassword = (await scrypt(credentials.password, salt, 64)).toString('hex');
|
|
||||||
const storedPassword = `${salt}/${hashedPassword}`;
|
|
||||||
|
|
||||||
const [{ id: userId }] = await knex('users')
|
const [{ id: userId }] = await knex('users')
|
||||||
.insert({
|
.insert({
|
||||||
@@ -152,7 +205,159 @@ export async function signup(credentials, userIp) {
|
|||||||
username: curatedUsername,
|
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) {
|
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) {
|
if (!user) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -35,7 +35,7 @@ export function curateUser(user, reqUser) {
|
|||||||
role: user.role,
|
role: user.role,
|
||||||
abilities: [...user.role_abilities || [], ...user.abilities || []],
|
abilities: [...user.role_abilities || [], ...user.abilities || []],
|
||||||
createdAt: user.created_at,
|
createdAt: user.created_at,
|
||||||
...(reqUser?.id === user.id ? privateUser : null),
|
...(reqUser?.id === user.id || options.includePrivate ? privateUser : null),
|
||||||
};
|
};
|
||||||
|
|
||||||
return curatedUser;
|
return curatedUser;
|
||||||
@@ -63,7 +63,7 @@ export async function fetchUser(userId, options = {}, reqUser) {
|
|||||||
.groupBy('users.id', 'users_roles.role')
|
.groupBy('users.id', 'users_roles.role')
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (!user) {
|
if (!user && options.throwError !== false) {
|
||||||
throw new HttpError(`User '${userId}' not found`, 404);
|
throw new HttpError(`User '${userId}' not found`, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ export async function fetchUser(userId, options = {}, reqUser) {
|
|||||||
return { user };
|
return { user };
|
||||||
}
|
}
|
||||||
|
|
||||||
return curateUser(user, reqUser);
|
return curateUser(user, reqUser, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchUserTemplates(reqUser) {
|
export async function fetchUserTemplates(reqUser) {
|
||||||
@@ -136,32 +136,3 @@ export async function createBan(ban, reqUser) {
|
|||||||
|
|
||||||
await knex('bans').insert(curatedBan);
|
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;
|
return updatedNotification;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const router = Router();
|
export const alertsRouter = Router();
|
||||||
|
|
||||||
router.get('/api/alerts', fetchAlertsApi);
|
alertsRouter.get('/api/alerts', fetchAlertsApi);
|
||||||
router.post('/api/alerts', createAlertApi);
|
alertsRouter.post('/api/alerts', createAlertApi);
|
||||||
router.delete('/api/alerts/:alertId', removeAlertApi);
|
alertsRouter.delete('/api/alerts/:alertId', removeAlertApi);
|
||||||
|
|
||||||
router.get('/api/users/:userId/notifications', fetchNotificationsApi);
|
alertsRouter.get('/api/users/:userId/notifications', fetchNotificationsApi);
|
||||||
router.patch('/api/users/:userId/notifications', updateNotificationsApi);
|
alertsRouter.patch('/api/users/:userId/notifications', updateNotificationsApi);
|
||||||
router.patch('/api/users/:userId/notifications/:notificationId', updateNotificationApi);
|
alertsRouter.patch('/api/users/:userId/notifications/:notificationId', updateNotificationApi);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
import { Router } from 'express';
|
||||||
import IPCIDR from 'ip-cidr';
|
import IPCIDR from 'ip-cidr';
|
||||||
import argv from '../argv.js';
|
import argv from '../argv.js';
|
||||||
|
|
||||||
@@ -8,10 +9,16 @@ import {
|
|||||||
flushUserKeys,
|
flushUserKeys,
|
||||||
login,
|
login,
|
||||||
removeUserKey,
|
removeUserKey,
|
||||||
|
sendEmailVerification,
|
||||||
signup,
|
signup,
|
||||||
|
updateSettings,
|
||||||
|
updateUser,
|
||||||
|
verifyEmail,
|
||||||
} from '../auth.js';
|
} 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
|
// changes every time the process restarts, so a deploy invalidates all cached session users
|
||||||
// even if they're within the staleness window below
|
// even if they're within the staleness window below
|
||||||
@@ -131,6 +138,60 @@ export async function signupApi(req, res) {
|
|||||||
res.send(user);
|
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) {
|
export async function fetchUserKeysApi(req, res) {
|
||||||
const keys = await fetchUserKeys(req.user);
|
const keys = await fetchUserKeys(req.user);
|
||||||
|
|
||||||
@@ -154,3 +215,28 @@ export async function flushUserKeysApi(req, res) {
|
|||||||
|
|
||||||
res.status(204).send();
|
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,
|
role: req.user.role,
|
||||||
abilities: req.user.abilities,
|
abilities: req.user.abilities,
|
||||||
avatar: req.user.avatar,
|
avatar: req.user.avatar,
|
||||||
|
isEmailVerified: req.user.isEmailVerified,
|
||||||
},
|
},
|
||||||
assets: req.user
|
assets: req.user
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -12,19 +12,11 @@ import initLogger from '../logger.js';
|
|||||||
|
|
||||||
import redis from '../redis.js';
|
import redis from '../redis.js';
|
||||||
import queryBoolean from '../utils/query-boolean.js';
|
import queryBoolean from '../utils/query-boolean.js';
|
||||||
import { actorsRouter } from './actors.js';
|
|
||||||
|
|
||||||
import { router as alertsRouter } from './alerts.js';
|
import { actorsRouter } from './actors.js';
|
||||||
import {
|
import { alertsRouter } from './alerts.js';
|
||||||
createKeyApi,
|
import { authRouter, setUserApi } from './auth.js';
|
||||||
fetchUserKeysApi,
|
|
||||||
flushUserKeysApi,
|
|
||||||
loginApi,
|
|
||||||
logoutApi,
|
|
||||||
removeUserKeyApi,
|
|
||||||
setUserApi,
|
|
||||||
signupApi,
|
|
||||||
} from './auth.js';
|
|
||||||
import consentHandler from './consent.js';
|
import consentHandler from './consent.js';
|
||||||
|
|
||||||
import { fetchEntitiesApi } from './entities.js';
|
import { fetchEntitiesApi } from './entities.js';
|
||||||
@@ -91,8 +83,6 @@ export default async function initServer() {
|
|||||||
store: redisStore,
|
store: redisStore,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
router.use(setUserApi);
|
|
||||||
|
|
||||||
// Vite integration
|
// Vite integration
|
||||||
if (isProduction) {
|
if (isProduction) {
|
||||||
app.enable('trust proxy');
|
app.enable('trust proxy');
|
||||||
@@ -118,6 +108,7 @@ export default async function initServer() {
|
|||||||
router.use(viteDevMiddleware);
|
router.use(viteDevMiddleware);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
router.use(setUserApi); // we need to set the IP before the restriction handler
|
||||||
router.use(restrictionHandler);
|
router.use(restrictionHandler);
|
||||||
|
|
||||||
router.get('/consent', (_req, res) => {
|
router.get('/consent', (_req, res) => {
|
||||||
@@ -134,22 +125,10 @@ export default async function initServer() {
|
|||||||
next();
|
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(userRouter);
|
||||||
router.use(stashesRouter);
|
router.use(stashesRouter);
|
||||||
router.use(alertsRouter);
|
router.use(alertsRouter);
|
||||||
|
router.use(authRouter);
|
||||||
router.use(scenesRouter);
|
router.use(scenesRouter);
|
||||||
router.use(actorsRouter);
|
router.use(actorsRouter);
|
||||||
router.use(syncRouter);
|
router.use(syncRouter);
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
fetchUser,
|
fetchUser,
|
||||||
fetchUserTemplates,
|
fetchUserTemplates,
|
||||||
removeTemplate,
|
removeTemplate,
|
||||||
updateSettings,
|
|
||||||
} from '../users.js';
|
} from '../users.js';
|
||||||
|
|
||||||
async function fetchUserApi(req, res) {
|
async function fetchUserApi(req, res) {
|
||||||
@@ -15,14 +14,6 @@ async function fetchUserApi(req, res) {
|
|||||||
res.send(user);
|
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) {
|
async function fetchUserTemplatesApi(req, res) {
|
||||||
const templates = await fetchUserTemplates(req.user);
|
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', fetchUserApi);
|
||||||
router.get('/api/users/:userId/templates', fetchUserTemplatesApi);
|
router.get('/api/users/:userId/templates', fetchUserTemplatesApi);
|
||||||
|
|
||||||
router.patch('/api/me/settings', updateSettingsApi);
|
|
||||||
|
|
||||||
router.post('/api/templates', createTemplateApi);
|
router.post('/api/templates', createTemplateApi);
|
||||||
router.delete('/api/templates/:templateId', removeTemplateApi);
|
router.delete('/api/templates/:templateId', removeTemplateApi);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user