You've already forked AstralRinth
forked from didirus/AstralRinth
Update master with new auth (#1236)
* Begin UI for threads and moderation overhaul * Hide close button on non-report threads * Fix review age coloring * Add project count * Remove action buttons from queue page and add queued date to project page * Hook up to actual data * Remove unused icon * Get up to 1000 projects in queue * prettier * more prettier * Changed all the things * lint * rebuild * Add omorphia * Workaround formatjs bug in ThreadSummary.vue * Fix notifications page on prod * Fix a few notifications and threads bugs * lockfile * Fix duplicate button styles * more fixes and polishing * More fixes * Remove legacy pages * More bugfixes * Add some error catching for reports and notifications * More error handling * fix lint * Add inbox links * Remove loading component and rename member header * Rely on threads always existing * Handle if project update notifs are not grouped * oops * Fix chips on notifications page * Import ModalModeration * finish threads * New authentication (#1234) * Initial new auth work * more auth pages * Finish most * more * fix on landing page * Finish everything but PATs + Sessions * fix threads merge bugs * fix cf pages ssr * fix most issues * Finish authentication * Fix merge --------- Co-authored-by: triphora <emma@modrinth.com> Co-authored-by: Jai A <jaiagr+gpg@pm.me> Co-authored-by: Geometrically <18202329+Geometrically@users.noreply.github.com>
This commit is contained in:
@@ -9,54 +9,259 @@
|
||||
:has-to-type="true"
|
||||
@proceed="deleteAccount"
|
||||
/>
|
||||
|
||||
<Modal ref="modal_revoke_token" header="Revoke your Modrinth token">
|
||||
<div class="modal-revoke-token markdown-body">
|
||||
<p>
|
||||
Revoking your Modrinth token can have unintended consequences. Please be aware that the
|
||||
following could break:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Any application that uses your token to access the API.</li>
|
||||
<li>Gradle - if Minotaur is given a incorrect token, your Gradle builds could fail.</li>
|
||||
<li>
|
||||
GitHub - if you use a GitHub action that uses the Modrinth API, it will cause errors.
|
||||
</li>
|
||||
</ul>
|
||||
<p>If you are willing to continue, complete the following steps:</p>
|
||||
<ol>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/settings/connections/applications/3acffb2e808d16d4b226"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Head to the Modrinth Application page on GitHub.
|
||||
</a>
|
||||
Make sure to be logged into the GitHub account you used for Modrinth!
|
||||
</li>
|
||||
<li>Press the big red "Revoke Access" button next to the "Permissions" header.</li>
|
||||
</ol>
|
||||
<p>Once you have completed those steps, press the continue button below.</p>
|
||||
<p>
|
||||
<strong>
|
||||
This will log you out of Modrinth, however, when you log back in, your token will be
|
||||
regenerated.
|
||||
</strong>
|
||||
</p>
|
||||
<div class="button-group">
|
||||
<button class="iconified-button" @click="$refs.modal_revoke_token.hide()">
|
||||
<CrossIcon />
|
||||
<Modal ref="changeEmailModal" :header="`${auth.user.email ? 'Change' : 'Add'} email`">
|
||||
<div class="universal-modal">
|
||||
<p>Your account information is not displayed publicly.</p>
|
||||
<label for="email-input"><span class="label__title">Email address</span> </label>
|
||||
<input
|
||||
id="email-input"
|
||||
v-model="email"
|
||||
maxlength="2048"
|
||||
type="email"
|
||||
:placeholder="`Enter your email address...`"
|
||||
/>
|
||||
<div class="input-group push-right">
|
||||
<button class="iconified-button" @click="$refs.changeEmailModal.hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
<button class="iconified-button brand-button" @click="logout">
|
||||
<RightArrowIcon />
|
||||
Log out
|
||||
<button
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
:disabled="!email"
|
||||
@click="saveEmail()"
|
||||
>
|
||||
<SaveIcon />
|
||||
Save email
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
ref="managePasswordModal"
|
||||
:header="`${
|
||||
removePasswordMode ? 'Remove' : auth.user.has_password ? 'Change' : 'Add'
|
||||
} password`"
|
||||
>
|
||||
<div class="universal-modal">
|
||||
<ul v-if="newPassword !== confirmNewPassword" class="known-errors">
|
||||
<li>Input passwords do not match!</li>
|
||||
</ul>
|
||||
<label v-if="removePasswordMode" for="old-password">
|
||||
<span class="label__title">Confirm password</span>
|
||||
<span class="label__description">Please enter your password to proceed.</span>
|
||||
</label>
|
||||
<label v-else-if="auth.user.has_password" for="old-password">
|
||||
<span class="label__title">Old password</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="auth.user.has_password"
|
||||
id="old-password"
|
||||
v-model="oldPassword"
|
||||
maxlength="2048"
|
||||
type="password"
|
||||
:placeholder="`${removePasswordMode ? 'Confirm' : 'Old'} password`"
|
||||
/>
|
||||
<template v-if="!removePasswordMode">
|
||||
<label for="new-password"><span class="label__title">New password</span></label>
|
||||
<input
|
||||
id="new-password"
|
||||
v-model="newPassword"
|
||||
maxlength="2048"
|
||||
type="password"
|
||||
placeholder="New password"
|
||||
/>
|
||||
<label for="confirm-new-password"
|
||||
><span class="label__title">Confirm new password</span></label
|
||||
>
|
||||
<input
|
||||
id="confirm-new-password"
|
||||
v-model="confirmNewPassword"
|
||||
maxlength="2048"
|
||||
type="password"
|
||||
placeholder="Confirm new password"
|
||||
/>
|
||||
</template>
|
||||
<p></p>
|
||||
<div class="input-group push-right">
|
||||
<button class="iconified-button" @click="$refs.managePasswordModal.hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
<template v-if="removePasswordMode">
|
||||
<button type="button" class="iconified-button danger-button" @click="savePassword">
|
||||
<TrashIcon />
|
||||
Remove password
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
v-if="auth.user.has_password && auth.user.auth_providers.length > 0"
|
||||
type="button"
|
||||
class="iconified-button danger-button"
|
||||
@click="removePasswordMode = true"
|
||||
>
|
||||
<TrashIcon />
|
||||
Remove password
|
||||
</button>
|
||||
<button type="button" class="iconified-button brand-button" @click="savePassword">
|
||||
<SaveIcon />
|
||||
Save password
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
ref="manageTwoFactorModal"
|
||||
:header="`${
|
||||
auth.user.has_totp && twoFactorStep === 0 ? 'Remove' : 'Setup'
|
||||
} two-factor authentication`"
|
||||
>
|
||||
<div class="universal-modal">
|
||||
<template v-if="auth.user.has_totp && twoFactorStep === 0">
|
||||
<label for="two-factor-code">
|
||||
<span class="label__title">Enter two-factor code</span>
|
||||
<span class="label__description">Please enter a two-factor code to proceed.</span>
|
||||
</label>
|
||||
<input
|
||||
id="two-factor-code"
|
||||
v-model="twoFactorCode"
|
||||
maxlength="11"
|
||||
type="text"
|
||||
placeholder="Enter code..."
|
||||
/>
|
||||
<p v-if="twoFactorIncorrect" class="known-errors">The code entered is incorrect!</p>
|
||||
<div class="input-group push-right">
|
||||
<button class="iconified-button" @click="$refs.manageTwoFactorModal.hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
<button class="iconified-button danger-button" @click="removeTwoFactor">
|
||||
<TrashIcon />
|
||||
Remove 2FA
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="twoFactorStep === 0">
|
||||
<p>
|
||||
Two-factor authentication keeps your account secure by requiring access to a second
|
||||
device in order to sign in.
|
||||
<br /><br />
|
||||
Scan the QR code with <a href="https://authy.com/">Authy</a>,
|
||||
<a href="https://www.microsoft.com/en-us/security/mobile-authenticator-app">
|
||||
Microsoft Authenticator</a
|
||||
>, or any other 2FA app to begin.
|
||||
</p>
|
||||
<qrcode-vue
|
||||
v-if="twoFactorSecret"
|
||||
:value="`otpauth://totp/${encodeURIComponent(
|
||||
auth.user.email
|
||||
)}?secret=${twoFactorSecret}&issuer=Modrinth`"
|
||||
:size="250"
|
||||
:margin="2"
|
||||
level="H"
|
||||
/>
|
||||
<p>
|
||||
If the QR code does not scan, you can manually enter the secret:
|
||||
<strong>{{ twoFactorSecret }}</strong>
|
||||
</p>
|
||||
</template>
|
||||
<template v-if="twoFactorStep === 1">
|
||||
<label for="verify-code">
|
||||
<span class="label__title">Verify code</span>
|
||||
<span class="label__description"
|
||||
>Enter the one-time code from authenticator to verify access.
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="verify-code"
|
||||
v-model="twoFactorCode"
|
||||
maxlength="6"
|
||||
type="number"
|
||||
placeholder="Enter code..."
|
||||
/>
|
||||
<p v-if="twoFactorIncorrect" class="known-errors">The code entered is incorrect!</p>
|
||||
</template>
|
||||
<template v-if="twoFactorStep === 2">
|
||||
<p>
|
||||
Download and save these back-up codes in a safe place. You can use these in-place of a
|
||||
2FA code if you ever lose access to your device! You should protect these codes like
|
||||
your password.
|
||||
</p>
|
||||
<p>Backup codes can only be used once.</p>
|
||||
<ul>
|
||||
<li v-for="code in backupCodes" :key="code">{{ code }}</li>
|
||||
</ul>
|
||||
</template>
|
||||
<div class="input-group push-right">
|
||||
<button v-if="twoFactorStep === 1" class="iconified-button" @click="twoFactorStep = 0">
|
||||
<LeftArrowIcon />
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
v-if="twoFactorStep !== 2"
|
||||
class="iconified-button"
|
||||
@click="$refs.manageTwoFactorModal.hide()"
|
||||
>
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
v-if="twoFactorStep <= 1"
|
||||
class="iconified-button brand-button"
|
||||
@click="twoFactorStep === 1 ? verifyTwoFactorCode() : (twoFactorStep = 1)"
|
||||
>
|
||||
<RightArrowIcon />
|
||||
Continue
|
||||
</button>
|
||||
<button
|
||||
v-if="twoFactorStep === 2"
|
||||
class="iconified-button brand-button"
|
||||
@click="$refs.manageTwoFactorModal.hide()"
|
||||
>
|
||||
<CheckIcon />
|
||||
Complete setup
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal ref="manageProvidersModal" header="Authentication providers">
|
||||
<div class="universal-modal">
|
||||
<div class="table">
|
||||
<div class="table-row table-head">
|
||||
<div class="table-cell table-text">Provider</div>
|
||||
<div class="table-cell table-text">Actions</div>
|
||||
</div>
|
||||
<div v-for="provider in authProviders" :key="provider.id" class="table-row">
|
||||
<div class="table-cell table-text">
|
||||
<span><component :is="provider.icon" /> {{ provider.display }}</span>
|
||||
</div>
|
||||
<div class="table-cell table-text manage">
|
||||
<button
|
||||
v-if="auth.user.auth_providers.includes(provider.id)"
|
||||
class="btn"
|
||||
@click="removeAuthProvider(provider.id)"
|
||||
>
|
||||
<TrashIcon /> Remove
|
||||
</button>
|
||||
<a v-else class="btn" :href="`${getAuthUrl(provider.id)}&token=${auth.token}`">
|
||||
<ExternalIcon /> Add
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p></p>
|
||||
<div class="input-group push-right">
|
||||
<button class="iconified-button brand-button" @click="$refs.manageProvidersModal.hide()">
|
||||
<CheckIcon />
|
||||
Finish editing
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<section class="universal-card">
|
||||
<h2>User profile</h2>
|
||||
<p>Visit your user profile to edit your profile information.</p>
|
||||
@@ -66,53 +271,83 @@
|
||||
</section>
|
||||
|
||||
<section class="universal-card">
|
||||
<h2>Account information</h2>
|
||||
<p>Your account information is not displayed publicly.</p>
|
||||
<ul class="known-errors">
|
||||
<li v-if="hasMonetizationEnabled() && !email">
|
||||
You must have an email address set since you are enrolled in the Creator Monetization
|
||||
Program.
|
||||
</li>
|
||||
</ul>
|
||||
<label for="email-input"><span class="label__title">Email address</span> </label>
|
||||
<input
|
||||
id="email-input"
|
||||
v-model="email"
|
||||
maxlength="2048"
|
||||
type="email"
|
||||
:placeholder="`Enter your email address...`"
|
||||
/>
|
||||
<div class="button-group">
|
||||
<button
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
:disabled="hasMonetizationEnabled() && !email"
|
||||
@click="saveChanges()"
|
||||
>
|
||||
<SaveIcon />
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<h2>Account security</h2>
|
||||
|
||||
<section class="universal-card">
|
||||
<h2>Authorization token</h2>
|
||||
<p>
|
||||
Your authorization token can be used with the Modrinth API, the Minotaur Gradle plugin, and
|
||||
other applications that interact with Modrinth's API. Be sure to keep this secret!
|
||||
</p>
|
||||
<div class="input-group">
|
||||
<button type="button" class="iconified-button" value="Copy to clipboard" @click="copyToken">
|
||||
<template v-if="copied">
|
||||
<CheckIcon />
|
||||
Copied token to clipboard
|
||||
</template>
|
||||
<template v-else> <CopyIcon />Copy token to clipboard </template>
|
||||
</button>
|
||||
<button type="button" class="iconified-button" @click="$refs.modal_revoke_token.show()">
|
||||
<SlashIcon />
|
||||
Revoke token
|
||||
</button>
|
||||
<div class="adjacent-input">
|
||||
<label for="theme-selector">
|
||||
<span class="label__title">Email</span>
|
||||
<span class="label__description">Changes the email associated with your account.</span>
|
||||
</label>
|
||||
<div>
|
||||
<button class="iconified-button" @click="$refs.changeEmailModal.show()">
|
||||
<template v-if="auth.user.email">
|
||||
<EditIcon />
|
||||
Change email
|
||||
</template>
|
||||
<template v-else>
|
||||
<PlusIcon />
|
||||
Add email
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label for="theme-selector">
|
||||
<span class="label__title">Password</span>
|
||||
<span v-if="auth.user.has_password" class="label__description">
|
||||
Change <template v-if="auth.user.auth_providers.length > 0">or remove</template> the
|
||||
password used to login to your account.
|
||||
</span>
|
||||
<span v-else class="label__description">
|
||||
Set a permanent password to login to your account.
|
||||
</span>
|
||||
</label>
|
||||
<div>
|
||||
<button
|
||||
class="iconified-button"
|
||||
@click="
|
||||
() => {
|
||||
oldPassword = ''
|
||||
newPassword = ''
|
||||
confirmNewPassword = ''
|
||||
removePasswordMode = false
|
||||
$refs.managePasswordModal.show()
|
||||
}
|
||||
"
|
||||
>
|
||||
<KeyIcon />
|
||||
<template v-if="auth.user.has_password"> Change password </template>
|
||||
<template v-else> Add password </template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label for="theme-selector">
|
||||
<span class="label__title">Two-factor authentication</span>
|
||||
<span class="label__description">
|
||||
Add an additional layer of security to your account during login.
|
||||
</span>
|
||||
</label>
|
||||
<div>
|
||||
<button class="iconified-button" @click="showTwoFactorModal">
|
||||
<template v-if="auth.user.has_totp"> <TrashIcon /> Remove 2FA </template>
|
||||
<template v-else> <PlusIcon /> Setup 2FA </template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label for="theme-selector">
|
||||
<span class="label__title">Manage authentication providers</span>
|
||||
<span class="label__description">
|
||||
Add or remove sign-on methods from your account, including GitHub, GitLab, Microsoft,
|
||||
Discord, Steam, and Google.
|
||||
</span>
|
||||
</label>
|
||||
<div>
|
||||
<button class="iconified-button" @click="$refs.manageProvidersModal.show()">
|
||||
<SettingsIcon /> Manage providers
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -134,128 +369,273 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import {
|
||||
EditIcon,
|
||||
UserIcon,
|
||||
SaveIcon,
|
||||
TrashIcon,
|
||||
PlusIcon,
|
||||
SettingsIcon,
|
||||
XIcon,
|
||||
LeftArrowIcon,
|
||||
RightArrowIcon,
|
||||
CheckIcon,
|
||||
GitHubIcon,
|
||||
ExternalIcon,
|
||||
} from 'omorphia'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
import DiscordIcon from 'assets/images/utils/discord.svg'
|
||||
import GoogleIcon from 'assets/images/utils/google.svg'
|
||||
import SteamIcon from 'assets/images/utils/steam.svg'
|
||||
import MicrosoftIcon from 'assets/images/utils/microsoft.svg'
|
||||
import GitLabIcon from 'assets/images/utils/gitlab.svg'
|
||||
import KeyIcon from '~/assets/images/utils/key.svg'
|
||||
import ModalConfirm from '~/components/ui/ModalConfirm.vue'
|
||||
import Modal from '~/components/ui/Modal.vue'
|
||||
|
||||
import CrossIcon from '~/assets/images/utils/x.svg'
|
||||
import RightArrowIcon from '~/assets/images/utils/right-arrow.svg'
|
||||
import CheckIcon from '~/assets/images/utils/check.svg'
|
||||
import UserIcon from '~/assets/images/utils/user.svg'
|
||||
import SaveIcon from '~/assets/images/utils/save.svg'
|
||||
import CopyIcon from '~/assets/images/utils/clipboard-copy.svg'
|
||||
import TrashIcon from '~/assets/images/utils/trash.svg'
|
||||
import SlashIcon from '~/assets/images/utils/slash.svg'
|
||||
useHead({
|
||||
title: 'Account settings - Modrinth',
|
||||
})
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
Modal,
|
||||
ModalConfirm,
|
||||
CrossIcon,
|
||||
RightArrowIcon,
|
||||
CheckIcon,
|
||||
SaveIcon,
|
||||
UserIcon,
|
||||
CopyIcon,
|
||||
TrashIcon,
|
||||
SlashIcon,
|
||||
},
|
||||
async setup() {
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const data = useNuxtApp()
|
||||
const auth = await useAuth()
|
||||
|
||||
const changeEmailModal = ref()
|
||||
const email = ref(auth.value.user.email)
|
||||
async function saveEmail() {
|
||||
if (!email.value) {
|
||||
return
|
||||
}
|
||||
|
||||
startLoading()
|
||||
try {
|
||||
await useBaseFetch(`auth/email`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
email: email.value,
|
||||
},
|
||||
})
|
||||
changeEmailModal.value.hide()
|
||||
await useAuth(auth.value.token)
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data.description,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
const managePasswordModal = ref()
|
||||
const removePasswordMode = ref(false)
|
||||
const oldPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
const confirmNewPassword = ref('')
|
||||
async function savePassword() {
|
||||
if (newPassword.value !== confirmNewPassword.value) {
|
||||
return
|
||||
}
|
||||
|
||||
startLoading()
|
||||
try {
|
||||
await useBaseFetch(`auth/password`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
old_password: auth.value.user.has_password ? oldPassword.value : null,
|
||||
new_password: removePasswordMode.value ? null : newPassword.value,
|
||||
},
|
||||
})
|
||||
managePasswordModal.value.hide()
|
||||
await useAuth(auth.value.token)
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data.description,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
const manageTwoFactorModal = ref()
|
||||
const twoFactorSecret = ref(null)
|
||||
const twoFactorFlow = ref(null)
|
||||
const twoFactorStep = ref(0)
|
||||
async function showTwoFactorModal() {
|
||||
twoFactorStep.value = 0
|
||||
twoFactorCode.value = null
|
||||
twoFactorIncorrect.value = false
|
||||
if (auth.value.user.has_totp) {
|
||||
manageTwoFactorModal.value.show()
|
||||
return
|
||||
}
|
||||
|
||||
twoFactorSecret.value = null
|
||||
twoFactorFlow.value = null
|
||||
backupCodes.value = []
|
||||
manageTwoFactorModal.value.show()
|
||||
|
||||
startLoading()
|
||||
try {
|
||||
const res = await useBaseFetch('auth/2fa/get_secret', {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
const auth = await useAuth()
|
||||
twoFactorSecret.value = res.secret
|
||||
twoFactorFlow.value = res.flow
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data.description,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
return { auth }
|
||||
const twoFactorIncorrect = ref(false)
|
||||
const twoFactorCode = ref(null)
|
||||
const backupCodes = ref([])
|
||||
async function verifyTwoFactorCode() {
|
||||
startLoading()
|
||||
try {
|
||||
const res = await useBaseFetch('auth/2fa', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
code: twoFactorCode.value ? twoFactorCode.value.toString() : '',
|
||||
flow: twoFactorFlow.value,
|
||||
},
|
||||
})
|
||||
|
||||
backupCodes.value = res.backup_codes
|
||||
twoFactorStep.value = 2
|
||||
await useAuth(auth.value.token)
|
||||
} catch (err) {
|
||||
twoFactorIncorrect.value = true
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
async function removeTwoFactor() {
|
||||
startLoading()
|
||||
try {
|
||||
await useBaseFetch('auth/2fa', {
|
||||
method: 'DELETE',
|
||||
body: {
|
||||
code: twoFactorCode.value ? twoFactorCode.value.toString() : '',
|
||||
},
|
||||
})
|
||||
manageTwoFactorModal.value.hide()
|
||||
await useAuth(auth.value.token)
|
||||
} catch (err) {
|
||||
twoFactorIncorrect.value = true
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
const authProviders = [
|
||||
{
|
||||
id: 'github',
|
||||
display: 'GitHub',
|
||||
icon: GitHubIcon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
copied: false,
|
||||
email: this.auth.user.email,
|
||||
showKnownErrors: false,
|
||||
}
|
||||
{
|
||||
id: 'gitlab',
|
||||
display: 'GitLab',
|
||||
icon: GitLabIcon,
|
||||
},
|
||||
head: {
|
||||
title: 'Account settings - Modrinth',
|
||||
{
|
||||
id: 'steam',
|
||||
display: 'Steam',
|
||||
icon: SteamIcon,
|
||||
},
|
||||
methods: {
|
||||
async copyToken() {
|
||||
this.copied = true
|
||||
await navigator.clipboard.writeText(this.auth.token)
|
||||
},
|
||||
async deleteAccount() {
|
||||
startLoading()
|
||||
try {
|
||||
await useBaseFetch(`user/${this.auth.user.id}`, {
|
||||
method: 'DELETE',
|
||||
...this.$defaultHeaders(),
|
||||
})
|
||||
} catch (err) {
|
||||
this.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data.description,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
|
||||
useCookie('auth-token').value = null
|
||||
alert('Please note that logging back in with GitHub will create a new account.')
|
||||
window.location.href = '/'
|
||||
|
||||
stopLoading()
|
||||
},
|
||||
logout() {
|
||||
this.$refs.modal_revoke_token.hide()
|
||||
useCookie('auth-token').value = null
|
||||
|
||||
window.location.href = getAuthUrl()
|
||||
},
|
||||
hasMonetizationEnabled() {
|
||||
return (
|
||||
this.auth.user.payout_data.payout_wallet &&
|
||||
this.auth.user.payout_data.payout_wallet_type &&
|
||||
this.auth.user.payout_data.payout_address
|
||||
)
|
||||
},
|
||||
async saveChanges() {
|
||||
if (this.hasMonetizationEnabled() && !this.email) {
|
||||
this.showKnownErrors = true
|
||||
return
|
||||
}
|
||||
startLoading()
|
||||
try {
|
||||
const data = {
|
||||
email: this.email ? this.email : null,
|
||||
}
|
||||
|
||||
await useBaseFetch(`user/${this.auth.user.id}`, {
|
||||
method: 'PATCH',
|
||||
body: data,
|
||||
...this.$defaultHeaders(),
|
||||
})
|
||||
await useAuth(this.auth.token)
|
||||
} catch (err) {
|
||||
this.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data.description,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
},
|
||||
{
|
||||
id: 'discord',
|
||||
display: 'Discord',
|
||||
icon: DiscordIcon,
|
||||
},
|
||||
})
|
||||
{
|
||||
id: 'microsoft',
|
||||
display: 'Microsoft',
|
||||
icon: MicrosoftIcon,
|
||||
},
|
||||
{
|
||||
id: 'google',
|
||||
display: 'Google',
|
||||
icon: GoogleIcon,
|
||||
},
|
||||
]
|
||||
|
||||
async function removeAuthProvider(provider) {
|
||||
startLoading()
|
||||
try {
|
||||
await useBaseFetch('auth/provider', {
|
||||
method: 'DELETE',
|
||||
body: {
|
||||
provider,
|
||||
},
|
||||
})
|
||||
await useAuth(auth.value.token)
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data.description,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
async function deleteAccount() {
|
||||
startLoading()
|
||||
try {
|
||||
await useBaseFetch(`user/${auth.value.user.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data.description,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
|
||||
useCookie('auth-token').value = null
|
||||
window.location.href = '/'
|
||||
|
||||
stopLoading()
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.modal-revoke-token {
|
||||
padding: var(--spacing-card-bg);
|
||||
canvas {
|
||||
margin: 0 auto;
|
||||
border-radius: var(--size-rounded-card);
|
||||
}
|
||||
|
||||
.button-group {
|
||||
width: fit-content;
|
||||
margin-left: auto;
|
||||
.table-row {
|
||||
grid-template-columns: 1fr 10rem;
|
||||
|
||||
span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: auto 0;
|
||||
|
||||
svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-right: 0.35rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
</label>
|
||||
<input
|
||||
id="search-layout-toggle"
|
||||
v-model="$cosmetics.searchLayout"
|
||||
v-model="cosmetics.searchLayout"
|
||||
class="switch stylized-toggle"
|
||||
type="checkbox"
|
||||
@change="saveCosmetics"
|
||||
@@ -49,7 +49,7 @@
|
||||
</label>
|
||||
<input
|
||||
id="project-layout-toggle"
|
||||
v-model="$cosmetics.projectLayout"
|
||||
v-model="cosmetics.projectLayout"
|
||||
class="switch stylized-toggle"
|
||||
type="checkbox"
|
||||
@change="saveCosmetics"
|
||||
@@ -71,8 +71,8 @@
|
||||
</label>
|
||||
<Multiselect
|
||||
:id="projectType + '-search-display-mode'"
|
||||
v-model="$cosmetics.searchDisplayMode[projectType.id]"
|
||||
:options="$tag.projectViewModes"
|
||||
v-model="cosmetics.searchDisplayMode[projectType.id]"
|
||||
:options="tags.projectViewModes"
|
||||
:custom-label="$capitalizeString"
|
||||
:searchable="false"
|
||||
:close-on-select="true"
|
||||
@@ -94,7 +94,7 @@
|
||||
</label>
|
||||
<input
|
||||
id="advanced-rendering"
|
||||
v-model="$cosmetics.advancedRendering"
|
||||
v-model="cosmetics.advancedRendering"
|
||||
class="switch stylized-toggle"
|
||||
type="checkbox"
|
||||
@change="saveCosmetics"
|
||||
@@ -107,7 +107,7 @@
|
||||
</label>
|
||||
<input
|
||||
id="modpacks-alpha-notice"
|
||||
v-model="$cosmetics.modpacksAlphaNotice"
|
||||
v-model="cosmetics.modpacksAlphaNotice"
|
||||
class="switch stylized-toggle"
|
||||
type="checkbox"
|
||||
@change="saveCosmetics"
|
||||
@@ -124,7 +124,7 @@
|
||||
</label>
|
||||
<input
|
||||
id="external-links-new-tab"
|
||||
v-model="$cosmetics.externalLinksNewTab"
|
||||
v-model="cosmetics.externalLinksNewTab"
|
||||
class="switch stylized-toggle"
|
||||
type="checkbox"
|
||||
@change="saveCosmetics"
|
||||
@@ -141,9 +141,15 @@ export default defineNuxtComponent({
|
||||
components: {
|
||||
Multiselect,
|
||||
},
|
||||
setup() {
|
||||
const cosmetics = useCosmetics()
|
||||
const tags = useTags()
|
||||
|
||||
return { cosmetics, tags }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
searchDisplayMode: this.$cosmetics.searchDisplayMode,
|
||||
searchDisplayMode: this.cosmetics.searchDisplayMode,
|
||||
}
|
||||
},
|
||||
head: {
|
||||
@@ -151,7 +157,7 @@ export default defineNuxtComponent({
|
||||
},
|
||||
computed: {
|
||||
listTypes() {
|
||||
const types = this.$tag.projectTypes.map((type) => {
|
||||
const types = this.tags.projectTypes.map((type) => {
|
||||
return {
|
||||
id: type.id,
|
||||
name: this.$formatProjectType(type.id) + ' search',
|
||||
|
||||
@@ -184,7 +184,6 @@ export default defineNuxtComponent({
|
||||
await useBaseFetch(`user/${this.auth.user.id}`, {
|
||||
method: 'PATCH',
|
||||
body: data,
|
||||
...this.$defaultHeaders(),
|
||||
})
|
||||
await useAuth(this.auth.token)
|
||||
|
||||
|
||||
296
pages/settings/pats.vue
Normal file
296
pages/settings/pats.vue
Normal file
@@ -0,0 +1,296 @@
|
||||
<template>
|
||||
<div class="universal-card">
|
||||
<Modal
|
||||
ref="patModal"
|
||||
:header="`${editPatIndex !== null ? 'Edit' : 'Create'} personal access token`"
|
||||
>
|
||||
<div class="universal-modal">
|
||||
<label for="pat-name"><span class="label__title">Name</span> </label>
|
||||
<input
|
||||
id="pat-name"
|
||||
v-model="name"
|
||||
maxlength="2048"
|
||||
type="email"
|
||||
placeholder="Enter the PAT's name..."
|
||||
/>
|
||||
<label for="pat-scopes"><span class="label__title">Scopes</span> </label>
|
||||
<div id="pat-scopes" class="checkboxes">
|
||||
<Checkbox
|
||||
v-for="(scope, index) in scopes"
|
||||
:key="scope"
|
||||
v-tooltip="
|
||||
scope.startsWith('_')
|
||||
? 'This scope is not allowed to be used with personal access tokens.'
|
||||
: null
|
||||
"
|
||||
:disabled="scope.startsWith('_')"
|
||||
:label="scope.replace('_', '')"
|
||||
:model-value="(scopesVal & (1 << index)) === 1 << index"
|
||||
@update:model-value="scopesVal ^= 1 << index"
|
||||
/>
|
||||
</div>
|
||||
<label for="pat-name"><span class="label__title">Expires</span> </label>
|
||||
<input id="pat-name" v-model="expires" type="date" />
|
||||
<p></p>
|
||||
<div class="input-group push-right">
|
||||
<button class="iconified-button" @click="$refs.patModal.hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
v-if="editPatIndex !== null"
|
||||
:disabled="loading || !name || !expires"
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
@click="editPat"
|
||||
>
|
||||
<SaveIcon />
|
||||
Save changes
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
:disabled="loading || !name || !expires"
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
@click="createPat"
|
||||
>
|
||||
<PlusIcon />
|
||||
Create PAT
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<div class="header__row">
|
||||
<div class="header__title">
|
||||
<h2>Personal Access Tokens</h2>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="
|
||||
() => {
|
||||
name = null
|
||||
scopesVal = 0
|
||||
expires = null
|
||||
editPatIndex = null
|
||||
$refs.patModal.show()
|
||||
}
|
||||
"
|
||||
>
|
||||
<PlusIcon /> Create a PAT
|
||||
</button>
|
||||
</div>
|
||||
<p>
|
||||
PATs can be used to access Modrinth's API. For more information, see
|
||||
<a class="text-link" href="https://docs.modrinth.com">Modrinth's API documentation</a>. They
|
||||
can be created and revoked at any time.
|
||||
</p>
|
||||
<div v-for="(pat, index) in pats" :key="pat.id" class="universal-card recessed token">
|
||||
<div>
|
||||
<div>
|
||||
<strong>{{ pat.name }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<template v-if="pat.access_token">
|
||||
<CopyCode :text="pat.access_token" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<span
|
||||
v-tooltip="
|
||||
pat.last_used ? $dayjs(pat.last_login).format('MMMM D, YYYY [at] h:mm A') : null
|
||||
"
|
||||
>
|
||||
<template v-if="pat.last_used">Last used {{ fromNow(pat.last_used) }}</template>
|
||||
<template v-else>Never used</template>
|
||||
</span>
|
||||
⋅
|
||||
<span v-tooltip="$dayjs(pat.expires).format('MMMM D, YYYY [at] h:mm A')">
|
||||
Expires {{ fromNow(pat.expires) }}
|
||||
</span>
|
||||
⋅
|
||||
<span v-tooltip="$dayjs(pat.created).format('MMMM D, YYYY [at] h:mm A')">
|
||||
Created {{ fromNow(pat.created) }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<button
|
||||
class="iconified-button raised-button"
|
||||
@click="
|
||||
() => {
|
||||
editPatIndex = index
|
||||
name = pat.name
|
||||
scopesVal = pat.scopes
|
||||
expires = $dayjs(pat.expires).format('YYYY-MM-DD')
|
||||
$refs.patModal.show()
|
||||
}
|
||||
"
|
||||
>
|
||||
<EditIcon /> Edit token
|
||||
</button>
|
||||
<button class="iconified-button raised-button" @click="removePat(pat.id)">
|
||||
<TrashIcon /> Revoke token
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { PlusIcon, Modal, XIcon, Checkbox, TrashIcon, EditIcon, SaveIcon } from 'omorphia'
|
||||
import CopyCode from '~/components/ui/CopyCode.vue'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'PATs - Modrinth',
|
||||
})
|
||||
|
||||
const scopes = [
|
||||
'Read user email',
|
||||
'Read user data',
|
||||
'Write user data',
|
||||
'_Delete your account',
|
||||
'_Write auth data',
|
||||
'Read notifications',
|
||||
'Write notifications',
|
||||
'Read payouts',
|
||||
'Write payouts',
|
||||
'Read analytics',
|
||||
'Create projects',
|
||||
'Read projects',
|
||||
'Write projects',
|
||||
'Delete projects',
|
||||
'Create versions',
|
||||
'Read versions',
|
||||
'Write versions',
|
||||
'Delete versions',
|
||||
'Create reports',
|
||||
'Read reports',
|
||||
'Write reports',
|
||||
'Delete reports',
|
||||
'Read threads',
|
||||
'Write threads',
|
||||
'_Create PATs',
|
||||
'_Read PATs',
|
||||
'_Write PATs',
|
||||
'_Delete PATs',
|
||||
'_Read sessions',
|
||||
'_Delete sessions',
|
||||
]
|
||||
|
||||
const data = useNuxtApp()
|
||||
const patModal = ref()
|
||||
|
||||
const editPatIndex = ref(null)
|
||||
|
||||
const name = ref(null)
|
||||
const scopesVal = ref(0)
|
||||
const expires = ref(null)
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const { data: pats, refresh } = await useAsyncData('pat', () => useBaseFetch('pat'))
|
||||
|
||||
async function createPat() {
|
||||
startLoading()
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await useBaseFetch('pat', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: name.value,
|
||||
scopes: scopesVal.value,
|
||||
expires: data.$dayjs(expires.value).toISOString(),
|
||||
},
|
||||
})
|
||||
pats.value.push(res)
|
||||
patModal.value.hide()
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
loading.value = false
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
async function editPat() {
|
||||
startLoading()
|
||||
loading.value = true
|
||||
try {
|
||||
await useBaseFetch(`pat/${pats.value[editPatIndex.value].id}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
name: name.value,
|
||||
scopes: scopesVal.value,
|
||||
expires: data.$dayjs(expires.value).toISOString(),
|
||||
},
|
||||
})
|
||||
await refresh()
|
||||
patModal.value.hide()
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
loading.value = false
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
async function removePat(id) {
|
||||
startLoading()
|
||||
try {
|
||||
pats.value = pats.value.filter((x) => x.id !== id)
|
||||
await useBaseFetch(`pat/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.checkboxes {
|
||||
display: grid;
|
||||
column-gap: 0.5rem;
|
||||
|
||||
@media screen and (min-width: 432px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 800px) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.token {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
@media screen and (min-width: 800px) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.input-group {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
89
pages/settings/sessions.vue
Normal file
89
pages/settings/sessions.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div class="universal-card">
|
||||
<h2>Sessions</h2>
|
||||
<p>
|
||||
Here are all the devices that are currently logged in with your Modrinth account. You can log
|
||||
out of each one individually.
|
||||
<br /><br />
|
||||
If you see an entry you don't recognize, log out of that device and change your Modrinth
|
||||
account password immediately.
|
||||
</p>
|
||||
<div v-for="session in sessions" :key="session.id" class="universal-card recessed session">
|
||||
<div>
|
||||
<div>
|
||||
<strong>
|
||||
{{ session.os ?? 'Unknown OS' }} ⋅ {{ session.platform ?? 'Unknown platform' }} ⋅
|
||||
{{ session.ip }}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<template v-if="session.city">{{ session.city }}, {{ session.country }} ⋅</template>
|
||||
<span v-tooltip="$dayjs(session.last_login).format('MMMM D, YYYY [at] h:mm A')">
|
||||
Last accessed {{ fromNow(session.last_login) }}
|
||||
</span>
|
||||
⋅
|
||||
<span v-tooltip="$dayjs(session.created).format('MMMM D, YYYY [at] h:mm A')">
|
||||
Created {{ fromNow(session.created) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<i v-if="session.current">Current session</i>
|
||||
<button v-else class="iconified-button raised-button" @click="revokeSession(session.id)">
|
||||
<XIcon /> Revoke session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { XIcon } from 'omorphia'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Sessions - Modrinth',
|
||||
})
|
||||
|
||||
const data = useNuxtApp()
|
||||
const { data: sessions, refresh } = await useAsyncData('session/list', () =>
|
||||
useBaseFetch('session/list')
|
||||
)
|
||||
|
||||
async function revokeSession(id) {
|
||||
startLoading()
|
||||
try {
|
||||
sessions.value = sessions.value.filter((x) => x.id !== id)
|
||||
await useBaseFetch(`session/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data.description,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.session {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
@media screen and (min-width: 800px) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.input-group {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user