Migrate to Turborepo (#1251)

This commit is contained in:
Evan Song
2024-07-04 21:46:29 -07:00
committed by GitHub
parent 6fa1acc461
commit 0f2ddb452c
811 changed files with 5623 additions and 7832 deletions

View File

@@ -0,0 +1,639 @@
<template>
<div>
<ModalConfirm
ref="modal_confirm"
title="Are you sure you want to delete your account?"
description="This will **immediately delete all of your user data and follows**. This will not delete your projects. Deleting your account cannot be reversed.<br><br>If you need help with your account, get support on the [Modrinth Discord](https://discord.modrinth.com)."
proceed-label="Delete this account"
:confirmation-text="auth.user.username"
:has-to-type="true"
@proceed="deleteAccount"
/>
<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...`"
@keyup.enter="saveEmail()"
/>
<div class="input-group push-right">
<button class="iconified-button" @click="$refs.changeEmailModal.hide()">
<XIcon />
Cancel
</button>
<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 && confirmNewPassword.length > 0"
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"
autocomplete="current-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"
autocomplete="new-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"
autocomplete="new-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"
:disabled="!oldPassword"
@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"
:disabled="
newPassword.length == 0 ||
(auth.user.has_password && oldPassword.length == 0) ||
newPassword !== confirmNewPassword
"
@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..."
@keyup.enter="removeTwoFactor()"
/>
<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="text"
autocomplete="one-time-code"
placeholder="Enter code..."
@keyup.enter="verifyTwoFactorCode()"
/>
<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, '/settings/account')}&token=${auth.token}`"
>
<ExternalIcon /> Add
</a>
</div>
</div>
</div>
<p></p>
<div class="input-group push-right">
<button class="iconified-button" @click="$refs.manageProvidersModal.hide()">
<XIcon />
Close
</button>
</div>
</div>
</Modal>
<section class="universal-card">
<h2>Account security</h2>
<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>
<section id="delete-account" class="universal-card">
<h2>Delete account</h2>
<p>
Once you delete your account, there is no going back. Deleting your account will remove all
attached data, excluding projects, from our servers.
</p>
<button
type="button"
class="iconified-button danger-button"
@click="$refs.modal_confirm.show()"
>
<TrashIcon />
Delete account
</button>
</section>
</div>
</template>
<script setup>
import {
EditIcon,
SaveIcon,
TrashIcon,
PlusIcon,
SettingsIcon,
XIcon,
LeftArrowIcon,
RightArrowIcon,
CheckIcon,
ExternalIcon,
} from '@modrinth/assets'
import QrcodeVue from 'qrcode.vue'
import GitHubIcon from 'assets/icons/auth/sso-github.svg'
import MicrosoftIcon from 'assets/icons/auth/sso-microsoft.svg'
import GoogleIcon from 'assets/icons/auth/sso-google.svg'
import SteamIcon from 'assets/icons/auth/sso-steam.svg'
import DiscordIcon from 'assets/icons/auth/sso-discord.svg'
import KeyIcon from 'assets/icons/auth/key.svg'
import GitLabIcon from 'assets/icons/auth/sso-gitlab.svg'
import ModalConfirm from '~/components/ui/ModalConfirm.vue'
import Modal from '~/components/ui/Modal.vue'
useHead({
title: 'Account settings - Modrinth',
})
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',
})
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()
}
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 : '',
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,
},
{
id: 'gitlab',
display: 'GitLab',
icon: GitLabIcon,
},
{
id: 'steam',
display: 'Steam',
icon: SteamIcon,
},
{
id: 'discord',
display: 'Discord',
icon: DiscordIcon,
},
{
id: 'microsoft',
display: 'Microsoft',
icon: MicrosoftIcon,
},
{
id: 'google',
display: 'Google',
icon: GoogleIcon,
},
]
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>
canvas {
margin: 0 auto;
border-radius: var(--size-rounded-card);
}
.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>

View File

@@ -0,0 +1,568 @@
<template>
<div class="universal-card">
<ConfirmModal
ref="modal_confirm"
title="Are you sure you want to delete this application?"
description="This will permanently delete this application and revoke all access tokens. (forever!)"
proceed-label="Delete this application"
@proceed="removeApp(editingId)"
/>
<Modal ref="appModal" header="Application information">
<div class="universal-modal">
<label for="app-name"><span class="label__title">Name</span> </label>
<input
id="app-name"
v-model="name"
maxlength="2048"
type="text"
autocomplete="off"
placeholder="Enter the application's name..."
/>
<label v-if="editingId" for="app-icon"><span class="label__title">Icon</span> </label>
<div v-if="editingId" class="icon-submission">
<Avatar size="md" :src="icon" />
<FileInput
:max-size="262144"
class="btn"
prompt="Upload icon"
accept="image/png,image/jpeg,image/gif,image/webp"
@change="onImageSelection"
>
<UploadIcon />
</FileInput>
</div>
<label v-if="editingId" for="app-url">
<span class="label__title">URL</span>
</label>
<input
v-if="editingId"
id="app-url"
v-model="url"
maxlength="255"
type="url"
autocomplete="off"
placeholder="https://example.com"
/>
<label v-if="editingId" for="app-description">
<span class="label__title">Description</span>
</label>
<textarea
v-if="editingId"
id="app-description"
v-model="description"
class="description-textarea"
maxlength="255"
type="text"
autocomplete="off"
placeholder="Enter the application's description..."
/>
<label for="app-scopes"><span class="label__title">Scopes</span> </label>
<div id="app-scopes" class="checkboxes">
<Checkbox
v-for="scope in scopeList"
:key="scope"
:label="scopesToLabels(getScopeValue(scope)).join(', ')"
:model-value="hasScope(scopesVal, scope)"
@update:model-value="() => (scopesVal = toggleScope(scopesVal, scope))"
/>
</div>
<label for="app-redirect-uris"><span class="label__title">Redirect uris</span> </label>
<div class="uri-input-list">
<div v-for="(_, index) in redirectUris" :key="index">
<div class="input-group url-input-group-fixes">
<input
v-model="redirectUris[index]"
maxlength="2048"
type="url"
autocomplete="off"
placeholder="https://example.com/auth/callback"
/>
<Button v-if="index !== 0" icon-only @click="() => redirectUris.splice(index, 1)">
<TrashIcon />
</Button>
<Button
v-if="index === 0"
color="primary"
icon-only
@click="() => redirectUris.push('')"
>
<PlusIcon /> Add more
</Button>
</div>
</div>
<div v-if="redirectUris.length <= 0">
<Button color="primary" icon-only @click="() => redirectUris.push('')">
<PlusIcon /> Add a redirect uri
</Button>
</div>
</div>
<div class="submit-row input-group push-right">
<button class="iconified-button" @click="$refs.appModal.hide()">
<XIcon />
Cancel
</button>
<button
v-if="editingId"
:disabled="!canSubmit"
type="button"
class="iconified-button brand-button"
@click="editApp"
>
<SaveIcon />
Save changes
</button>
<button
v-else
:disabled="!canSubmit"
type="button"
class="iconified-button brand-button"
@click="createApp"
>
<PlusIcon />
Create App
</button>
</div>
</div>
</Modal>
<div class="header__row">
<div class="header__title">
<h2>{{ formatMessage(commonSettingsMessages.applications) }}</h2>
</div>
<button
class="btn btn-primary"
@click="
() => {
name = null
icon = null
scopesVal = 0
redirectUris = ['']
editingId = null
expires = null
$refs.appModal.show()
}
"
>
<PlusIcon /> New Application
</button>
</div>
<p>
Applications can be used to authenticate Modrinth's users with your products. For more
information, see
<a class="text-link" href="https://docs.modrinth.com">Modrinth's API documentation</a>.
</p>
<div v-for="app in usersApps" :key="app.id" class="universal-card recessed token">
<div class="token-info">
<div class="token-icon">
<Avatar size="sm" :src="app.icon_url" />
<div>
<h2 class="token-title">{{ app.name }}</h2>
<div>Created on {{ new Date(app.created).toLocaleDateString() }}</div>
</div>
</div>
<div>
<label for="token-information">
<span class="label__title">About</span>
</label>
<div class="token-content">
<div>
Client ID
<CopyCode :text="app.id" />
</div>
<div v-if="!!clientCreatedInState(app.id)">
<div>
Client Secret <CopyCode :text="clientCreatedInState(app.id)?.client_secret" />
</div>
<div class="secret_disclaimer">
<i> Save your secret now, it will be hidden after you leave this page! </i>
</div>
</div>
</div>
</div>
</div>
<div class="input-group">
<Button
icon-only
@click="
() => {
setForm({
...app,
redirect_uris: app.redirect_uris.map((u) => u.uri) || [],
})
$refs.appModal.show()
}
"
>
<EditIcon />
Edit
</Button>
<Button
color="danger"
icon-only
@click="
() => {
editingId = app.id
$refs.modal_confirm.show()
}
"
>
<TrashIcon />
Delete
</Button>
</div>
</div>
</div>
</template>
<script setup>
import { UploadIcon, PlusIcon, XIcon, TrashIcon, EditIcon, SaveIcon } from '@modrinth/assets'
import { CopyCode, ConfirmModal, Button, Checkbox, Avatar, FileInput } from '@modrinth/ui'
import Modal from '~/components/ui/Modal.vue'
import {
scopeList,
hasScope,
toggleScope,
useScopes,
getScopeValue,
} from '~/composables/auth/scopes.ts'
import { commonSettingsMessages } from '~/utils/common-messages.ts'
const { formatMessage } = useVIntl()
definePageMeta({
middleware: 'auth',
})
useHead({
title: 'Applications - Modrinth',
})
const data = useNuxtApp()
const { scopesToLabels } = useScopes()
const appModal = ref()
// Any apps created in the current state will be stored here
// Users can copy Client Secrets and such before the page reloads
const createdApps = ref([])
const editingId = ref(null)
const name = ref(null)
const icon = ref(null)
const scopesVal = ref(BigInt(0))
const redirectUris = ref([''])
const url = ref(null)
const description = ref(null)
const loading = ref(false)
const auth = await useAuth()
const { data: usersApps, refresh } = await useAsyncData(
'usersApps',
() =>
useBaseFetch(`user/${auth.value.user.id}/oauth_apps`, {
apiVersion: 3,
}),
{
watch: [auth],
}
)
const setForm = (app) => {
if (app?.id) {
editingId.value = app.id
} else {
editingId.value = null
}
name.value = app?.name || ''
icon.value = app?.icon_url || ''
scopesVal.value = app?.max_scopes || BigInt(0)
url.value = app?.url || ''
description.value = app?.description || ''
if (app?.redirect_uris) {
redirectUris.value = app.redirect_uris.map((uri) => uri?.uri || uri)
} else {
redirectUris.value = ['']
}
}
const canSubmit = computed(() => {
// Make sure name, scopes, and return uri are at least filled in
const filledIn =
name.value && name.value !== '' && name.value?.length > 2 && redirectUris.value.length > 0
// Make sure the redirect uris are either one empty string or all filled in with valid urls
const oneValid = redirectUris.value.length === 1 && redirectUris.value[0] === ''
let allValid
try {
allValid = redirectUris.value.every((uri) => {
const url = new URL(uri)
return !!url
})
} catch (err) {
allValid = false
}
return filledIn && (oneValid || allValid)
})
const clientCreatedInState = (id) => {
return createdApps.value.find((app) => app.id === id)
}
async function onImageSelection(files) {
if (!editingId.value) {
throw new Error('No editing id')
}
if (files.length > 0) {
const file = files[0]
const extFromType = file.type.split('/')[1]
await useBaseFetch('oauth/app/' + editingId.value + '/icon', {
method: 'PATCH',
internal: true,
body: file,
query: {
ext: extFromType,
},
})
await refresh()
const app = usersApps.value.find((app) => app.id === editingId.value)
if (app) {
setForm(app)
}
data.$notify({
group: 'main',
title: 'Icon updated',
text: 'Your application icon has been updated.',
type: 'success',
})
}
}
async function createApp() {
startLoading()
loading.value = true
try {
const createdAppInfo = await useBaseFetch('oauth/app', {
method: 'POST',
internal: true,
body: {
name: name.value,
icon_url: icon.value,
max_scopes: Number(scopesVal.value), // JS is 52 bit for ints so we're good for now
redirect_uris: redirectUris.value,
},
})
createdApps.value.push(createdAppInfo)
setForm(null)
appModal.value.hide()
await refresh()
} 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 editApp() {
startLoading()
loading.value = true
try {
if (!editingId.value) {
throw new Error('No editing id')
}
// check if there's any difference between the current app and the one in the state
const app = usersApps.value.find((app) => app.id === editingId.value)
if (!app) {
throw new Error('No app found')
}
if (
app.name === name.value &&
app.icon_url === icon.value &&
app.max_scopes === scopesVal.value &&
app.redirect_uris === redirectUris.value &&
app.url === url.value &&
app.description === description.value
) {
setForm(null)
editingId.value = null
appModal.value.hide()
throw new Error('No changes detected')
}
const body = {
name: name.value,
max_scopes: Number(scopesVal.value), // JS is 52 bit for ints so we're good for now
redirect_uris: redirectUris.value,
}
if (url.value && url.value?.length > 0) {
body.url = url.value
}
if (description.value && description.value?.length > 0) {
body.description = description.value
}
if (icon.value && icon.value?.length > 0) {
body.icon_url = icon.value
}
await useBaseFetch('oauth/app/' + editingId.value, {
method: 'PATCH',
internal: true,
body,
})
await refresh()
setForm(null)
editingId.value = null
appModal.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 removeApp() {
startLoading()
try {
if (!editingId.value) {
throw new Error('No editing id')
}
await useBaseFetch(`oauth/app/${editingId.value}`, {
internal: true,
method: 'DELETE',
})
await refresh()
editingId.value = null
} 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>
.description-textarea {
height: 6rem;
resize: vertical;
}
.secret_disclaimer {
font-size: var(--font-size-sm);
}
.submit-row {
padding-top: var(--gap-lg);
}
.uri-input-list {
display: grid;
row-gap: 0.5rem;
}
.url-input-group-fixes {
width: 100%;
input {
width: 100% !important;
flex-basis: 24rem !important;
}
}
.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);
}
}
.icon-submission {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-md);
}
.token {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--gap-sm);
.token-info {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
}
.token-content {
display: grid;
gap: var(--gap-xs);
}
.token-icon {
display: flex;
align-items: flex-start;
gap: var(--gap-lg);
padding-bottom: var(--gap-sm);
}
.token-heading {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
color: var(--color-gray-700);
margin-top: var(--spacing-card-md);
margin-bottom: var(--spacing-card-sm);
}
.token-title {
margin-bottom: var(--spacing-card-xs);
}
.input-group {
margin-left: auto;
// For the children override the padding so that y padding is --gap-sm and x padding is --gap-lg
// Knossos global styling breaks everything
> * {
padding: var(--gap-sm) var(--gap-lg);
}
}
@media screen and (min-width: 800px) {
flex-direction: row;
}
}
</style>

View File

@@ -0,0 +1,245 @@
<template>
<div class="universal-card">
<ConfirmModal
ref="modal_confirm"
title="Are you sure you want to revoke this application?"
description="This will revoke the application's access to your account. You can always re-authorize it later."
proceed-label="Revoke"
@proceed="revokeApp(revokingId)"
/>
<h2>{{ formatMessage(commonSettingsMessages.authorizedApps) }}</h2>
<p>
When you authorize an application with your Modrinth account, you grant it access to your
account. You can manage and review access to your account here at any time.
</p>
<div v-if="appInfoLookup.length === 0" class="universal-card recessed">
You have not authorized any applications.
</div>
<div
v-for="authorization in appInfoLookup"
:key="authorization.id"
class="universal-card recessed token"
>
<div class="token-content">
<div>
<div class="icon-name">
<Avatar :src="authorization.app.icon_url" />
<div>
<h2 class="token-title">
{{ authorization.app.name }}
</h2>
<div>
by
<nuxt-link class="text-link" :to="'/user/' + authorization.owner.id">{{
authorization.owner.username
}}</nuxt-link>
<template v-if="authorization.app.url">
<span> </span>
<nuxt-link class="text-link" :to="authorization.app.url">
{{ authorization.app.url }}
</nuxt-link>
</template>
</div>
</div>
</div>
</div>
<div>
<template v-if="authorization.app.description">
<label for="app-description">
<span class="label__title"> About this app </span>
</label>
<div id="app-description">{{ authorization.app.description }}</div>
</template>
<label for="app-scope-list">
<span class="label__title">Scopes</span>
</label>
<div class="scope-list">
<div
v-for="scope in scopesToDefinitions(authorization.scopes)"
:key="scope"
class="scope-list-item"
>
<div class="scope-list-item-icon">
<CheckIcon />
</div>
{{ scope }}
</div>
</div>
</div>
</div>
<div class="input-group">
<Button
color="danger"
icon-only
@click="
() => {
revokingId = authorization.app_id
$refs.modal_confirm.show()
}
"
>
<TrashIcon />
Revoke
</Button>
</div>
</div>
</div>
</template>
<script setup>
import { Button, ConfirmModal, Avatar } from '@modrinth/ui'
import { TrashIcon, CheckIcon } from '@modrinth/assets'
import { commonSettingsMessages } from '~/utils/common-messages.ts'
import { useScopes } from '~/composables/auth/scopes.ts'
const { formatMessage } = useVIntl()
const { scopesToDefinitions } = useScopes()
const revokingId = ref(null)
definePageMeta({
middleware: 'auth',
})
useHead({
title: 'Authorizations - Modrinth',
})
const { data: usersApps, refresh } = await useAsyncData('userAuthorizations', () =>
useBaseFetch(`oauth/authorizations`, {
internal: true,
})
)
const { data: appInformation } = await useAsyncData(
'appInfo',
() =>
useBaseFetch('oauth/apps', {
internal: true,
query: {
ids: usersApps.value.map((c) => c.app_id).join(','),
},
}),
{
watch: usersApps,
}
)
const { data: appCreatorsInformation } = await useAsyncData(
'appCreatorsInfo',
() =>
useBaseFetch('users', {
query: {
ids: JSON.stringify(appInformation.value.map((c) => c.created_by)),
},
}),
{
watch: appInformation,
}
)
const appInfoLookup = computed(() => {
return usersApps.value.map((app) => {
const info = appInformation.value.find((c) => c.id === app.app_id)
const owner = appCreatorsInformation.value.find((c) => c.id === info.created_by)
return {
...app,
app: info || null,
owner: owner || null,
}
})
})
async function revokeApp(id) {
try {
await useBaseFetch(`oauth/authorizations`, {
internal: true,
method: 'DELETE',
query: {
client_id: id,
},
})
revokingId.value = null
await refresh()
} catch (err) {
data.$notify({
group: 'main',
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
}
</script>
<style lang="scss" scoped>
.input-group {
// Overrides for omorphia compat
> * {
padding: var(--gap-sm) var(--gap-lg) !important;
}
}
.scope-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
gap: var(--gap-sm);
.scope-list-item {
display: flex;
align-items: center;
gap: 0.5rem;
border-radius: 0.25rem;
background-color: var(--color-gray-200);
color: var(--color-gray-700);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.25rem;
// avoid breaking text or overflowing
white-space: nowrap;
overflow: hidden;
}
.scope-list-item-icon {
width: 1.25rem;
height: 1.25rem;
flex: 0 0 auto;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-green);
color: var(--color-raised-bg);
}
}
.icon-name {
display: flex;
align-items: flex-start;
gap: var(--gap-lg);
padding-bottom: var(--gap-sm);
}
.token-content {
width: 100%;
.token-title {
margin-bottom: var(--spacing-card-xs);
}
}
.token {
display: flex;
flex-direction: column;
gap: 0.5rem;
@media screen and (min-width: 800px) {
flex-direction: row;
align-items: flex-start;
}
}
</style>

View File

@@ -0,0 +1,646 @@
<template>
<div>
<MessageBanner v-if="flags.developerMode" message-type="warning" class="developer-message">
<CodeIcon />
<IntlFormatted :message-id="developerModeBanner.description">
<template #strong="{ children }">
<strong>
<component :is="() => normalizeChildren(children)" />
</strong>
</template>
</IntlFormatted>
<Button :action="() => disableDeveloperMode()">
{{ formatMessage(developerModeBanner.deactivate) }}
</Button>
</MessageBanner>
<section class="universal-card">
<h2>{{ formatMessage(colorTheme.title) }}</h2>
<p>{{ formatMessage(colorTheme.description) }}</p>
<div class="theme-options">
<button
v-for="option in themeOptions"
:key="option"
class="preview-radio button-base"
:class="{ selected: theme.preference === option }"
@click="() => updateColorTheme(option)"
>
<div class="preview" :class="`${option === 'system' ? systemTheme : option}-mode`">
<div class="example-card card card">
<div class="example-icon"></div>
<div class="example-text-1"></div>
<div class="example-text-2"></div>
</div>
</div>
<div class="label">
<RadioButtonChecked v-if="theme.preference === option" class="radio" />
<RadioButtonIcon v-else class="radio" />
{{ colorTheme[option] ? formatMessage(colorTheme[option]) : option }}
<SunIcon
v-if="'light' === option"
v-tooltip="formatMessage(colorTheme.preferredLight)"
class="theme-icon"
/>
<MoonIcon
v-else-if="(cosmetics.preferredDarkTheme ?? 'dark') === option"
v-tooltip="formatMessage(colorTheme.preferredDark)"
class="theme-icon"
/>
</div>
</button>
</div>
</section>
<section class="universal-card">
<h2>{{ formatMessage(projectListLayouts.title) }}</h2>
<p>{{ formatMessage(projectListLayouts.description) }}</p>
<div class="project-lists">
<div v-for="projectType in listTypes" :key="projectType.id + '-project-list-layouts'">
<div class="label">
<div class="label__title">
{{
projectListLayouts[projectType.id]
? formatMessage(projectListLayouts[projectType.id])
: projectType.id
}}
</div>
</div>
<div class="project-list-layouts">
<button
class="preview-radio button-base"
:class="{ selected: cosmetics.searchDisplayMode[projectType.id] === 'list' }"
@click="() => (cosmetics.searchDisplayMode[projectType.id] = 'list')"
>
<div class="preview">
<div class="layout-list-mode">
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
</div>
</div>
<div class="label">
<RadioButtonChecked
v-if="cosmetics.searchDisplayMode[projectType.id] === 'list'"
class="radio"
/>
<RadioButtonIcon v-else class="radio" />
Rows
</div>
</button>
<button
class="preview-radio button-base"
:class="{ selected: cosmetics.searchDisplayMode[projectType.id] === 'grid' }"
@click="() => (cosmetics.searchDisplayMode[projectType.id] = 'grid')"
>
<div class="preview">
<div class="layout-grid-mode">
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
</div>
</div>
<div class="label">
<RadioButtonChecked
v-if="cosmetics.searchDisplayMode[projectType.id] === 'grid'"
class="radio"
/>
<RadioButtonIcon v-else class="radio" />
Grid
</div>
</button>
<button
class="preview-radio button-base"
:class="{ selected: cosmetics.searchDisplayMode[projectType.id] === 'gallery' }"
@click="() => (cosmetics.searchDisplayMode[projectType.id] = 'gallery')"
>
<div class="preview">
<div class="layout-gallery-mode">
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
<div class="example-card card"></div>
</div>
</div>
<div class="label">
<RadioButtonChecked
v-if="cosmetics.searchDisplayMode[projectType.id] === 'gallery'"
class="radio"
/>
<RadioButtonIcon v-else class="radio" />
Gallery
</div>
</button>
</div>
</div>
</div>
</section>
<section class="universal-card">
<h2>{{ formatMessage(toggleFeatures.title) }}</h2>
<p>{{ formatMessage(toggleFeatures.description) }}</p>
<div class="adjacent-input small">
<label for="advanced-rendering">
<span class="label__title">
{{ formatMessage(toggleFeatures.advancedRenderingTitle) }}
</span>
<span class="label__description">
{{ formatMessage(toggleFeatures.advancedRenderingDescription) }}
</span>
</label>
<input
id="advanced-rendering"
v-model="cosmetics.advancedRendering"
class="switch stylized-toggle"
type="checkbox"
@change="saveCosmetics"
/>
</div>
<div class="adjacent-input small">
<label for="external-links-new-tab">
<span class="label__title">
{{ formatMessage(toggleFeatures.externalLinksNewTabTitle) }}
</span>
<span class="label__description">
{{ formatMessage(toggleFeatures.externalLinksNewTabDescription) }}
</span>
</label>
<input
id="external-links-new-tab"
v-model="cosmetics.externalLinksNewTab"
class="switch stylized-toggle"
type="checkbox"
@change="saveCosmetics"
/>
</div>
<div class="adjacent-input small">
<label for="modrinth-app-promos">
<span class="label__title">
{{ formatMessage(toggleFeatures.hideModrinthAppPromosTitle) }}
</span>
<span class="label__description">
{{ formatMessage(toggleFeatures.hideModrinthAppPromosDescription) }}
</span>
</label>
<input
id="modrinth-app-promos"
v-model="cosmetics.hideModrinthAppPromos"
class="switch stylized-toggle"
type="checkbox"
@change="saveCosmetics"
/>
</div>
<div class="adjacent-input small">
<label for="search-layout-toggle">
<span class="label__title">
{{ formatMessage(toggleFeatures.rightAlignedSearchSidebarTitle) }}
</span>
<span class="label__description">
{{ formatMessage(toggleFeatures.rightAlignedSearchSidebarDescription) }}
</span>
</label>
<input
id="search-layout-toggle"
v-model="cosmetics.searchLayout"
class="switch stylized-toggle"
type="checkbox"
@change="saveCosmetics"
/>
</div>
<div class="adjacent-input small">
<label for="project-layout-toggle">
<span class="label__title">
{{ formatMessage(toggleFeatures.rightAlignedProjectSidebarTitle) }}
</span>
<span class="label__description">
{{ formatMessage(toggleFeatures.rightAlignedProjectSidebarDescription) }}
</span>
</label>
<input
id="project-layout-toggle"
v-model="cosmetics.projectLayout"
class="switch stylized-toggle"
type="checkbox"
@change="saveCosmetics"
/>
</div>
</section>
</div>
</template>
<script setup>
import { CodeIcon, RadioButtonIcon, RadioButtonChecked, SunIcon, MoonIcon } from '@modrinth/assets'
import { Button } from '@modrinth/ui'
import { formatProjectType } from '~/plugins/shorthands.js'
import MessageBanner from '~/components/ui/MessageBanner.vue'
import { DARK_THEMES } from '~/composables/theme.js'
useHead({
title: 'Display settings - Modrinth',
})
const { formatMessage } = useVIntl()
const developerModeBanner = defineMessages({
description: {
id: 'settings.display.banner.developer-mode.description',
defaultMessage:
"<strong>Developer mode</strong> is active. This will allow you to view the internal IDs of various things throughout Modrinth that may be helpful if you're a developer using the Modrinth API. Click on the Modrinth logo at the bottom of the page 5 times to toggle developer mode.",
},
deactivate: {
id: 'settings.display.banner.developer-mode.button',
defaultMessage: 'Deactivate developer mode',
},
})
const colorTheme = defineMessages({
title: {
id: 'settings.display.theme.title',
defaultMessage: 'Color theme',
},
description: {
id: 'settings.display.theme.description',
defaultMessage: 'Select your preferred color theme for Modrinth on this device.',
},
system: {
id: 'settings.display.theme.system',
defaultMessage: 'Sync with system',
},
light: {
id: 'settings.display.theme.light',
defaultMessage: 'Light',
},
dark: {
id: 'settings.display.theme.dark',
defaultMessage: 'Dark',
},
oled: {
id: 'settings.display.theme.oled',
defaultMessage: 'OLED',
},
retro: {
id: 'settings.display.theme.retro',
defaultMessage: 'Retro',
},
preferredLight: {
id: 'settings.display.theme.preferred-light-theme',
defaultMessage: 'Preferred light theme',
},
preferredDark: {
id: 'settings.display.theme.preferred-dark-theme',
defaultMessage: 'Preferred dark theme',
},
})
const projectListLayouts = defineMessages({
title: {
id: 'settings.display.project-list-layouts.title',
defaultMessage: 'Project list layouts',
},
description: {
id: 'settings.display.project-list-layouts.description',
defaultMessage:
'Select your preferred layout for each page that displays project lists on this device.',
},
mod: {
id: 'settings.display.project-list-layouts.mod',
defaultMessage: 'Mods page',
},
plugin: {
id: 'settings.display.project-list-layouts.plugin',
defaultMessage: 'Plugins page',
},
datapack: {
id: 'settings.display.project-list-layouts.datapack',
defaultMessage: 'Data Packs page',
},
shader: {
id: 'settings.display.project-list-layouts.shader',
defaultMessage: 'Shaders page',
},
resourcepack: {
id: 'settings.display.project-list-layouts.resourcepack',
defaultMessage: 'Resource Packs page',
},
modpack: {
id: 'settings.display.project-list-layouts.modpack',
defaultMessage: 'Modpacks page',
},
user: {
id: 'settings.display.project-list-layouts.user',
defaultMessage: 'User profile pages',
},
})
const toggleFeatures = defineMessages({
title: {
id: 'settings.display.flags.title',
defaultMessage: 'Toggle features',
},
description: {
id: 'settings.display.flags.description',
defaultMessage: 'Enable or disable certain features on this device.',
},
advancedRenderingTitle: {
id: 'settings.display.sidebar.advanced-rendering.title',
defaultMessage: 'Advanced rendering',
},
advancedRenderingDescription: {
id: 'settings.display.sidebar.advanced-rendering.description',
defaultMessage:
'Enables advanced rendering such as blur effects that may cause performance issues without hardware-accelerated rendering.',
},
externalLinksNewTabTitle: {
id: 'settings.display.sidebar.external-links-new-tab.title',
defaultMessage: 'Open external links in new tab',
},
externalLinksNewTabDescription: {
id: 'settings.display.sidebar.external-links-new-tab.description',
defaultMessage:
'Make links which go outside of Modrinth open in a new tab. No matter this setting, links on the same domain and in Markdown descriptions will open in the same tab, and links on ads and edit pages will open in a new tab.',
},
hideModrinthAppPromosTitle: {
id: 'settings.display.sidebar.hide-app-promos.title',
defaultMessage: 'Hide Modrinth App promotions',
},
hideModrinthAppPromosDescription: {
id: 'settings.display.sidebar.hide-app-promos.description',
defaultMessage:
'Hides the "Get Modrinth App" buttons from primary navigation. The Modrinth App page can still be found on the landing page or in the footer.',
},
rightAlignedSearchSidebarTitle: {
id: 'settings.display.sidebar.right-aligned-search-sidebar.title',
defaultMessage: 'Right-aligned search sidebar',
},
rightAlignedSearchSidebarDescription: {
id: 'settings.display.sidebar.right-aligned-search-sidebar.description',
defaultMessage: 'Aligns the search filters sidebar to the right of the search results.',
},
rightAlignedProjectSidebarTitle: {
id: 'settings.display.sidebar.right-aligned-project-sidebar.title',
defaultMessage: 'Right-aligned project sidebar',
},
rightAlignedProjectSidebarDescription: {
id: 'settings.display.sidebar.right-aligned-project-sidebar.description',
defaultMessage: "Aligns the project details sidebar to the right of the page's content.",
},
})
const cosmetics = useCosmetics()
const flags = useFeatureFlags()
const tags = useTags()
const systemTheme = ref('light')
const theme = useTheme()
const themeOptions = computed(() => {
const options = ['system', 'light', 'dark', 'oled']
if (flags.value.developerMode || theme.value.preference === 'retro') {
options.push('retro')
}
return options
})
onMounted(() => {
updateSystemTheme()
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', (event) => {
setSystemTheme(event.matches)
})
})
function updateSystemTheme() {
const query = window.matchMedia('(prefers-color-scheme: light)')
setSystemTheme(query.matches)
}
function setSystemTheme(light) {
if (light) {
systemTheme.value = 'light'
} else {
systemTheme.value = cosmetics.value.preferredDarkTheme ?? 'dark'
}
}
function updateColorTheme(value) {
if (DARK_THEMES.includes(value)) {
cosmetics.value.preferredDarkTheme = value
saveCosmetics()
updateSystemTheme()
}
updateTheme(value, true)
}
function disableDeveloperMode() {
flags.value.developerMode = !flags.value.developerMode
saveFeatureFlags()
addNotification({
group: 'main',
title: 'Developer mode deactivated',
text: 'Developer mode has been disabled',
type: 'success',
})
}
const listTypes = computed(() => {
const types = tags.value.projectTypes.map((type) => {
return {
id: type.id,
name: formatProjectType(type.id) + 's',
display: 'the ' + formatProjectType(type.id).toLowerCase() + 's search page',
}
})
types.push({
id: 'user',
name: 'User profiles',
display: 'user pages',
})
return types
})
</script>
<style scoped lang="scss">
.preview-radio {
width: 100%;
border-radius: var(--radius-md);
padding: 0;
overflow: hidden;
border: 1px solid var(--color-divider);
background-color: var(--color-button-bg);
color: var(--color-base);
display: flex;
flex-direction: column;
outline: 2px solid transparent;
&.selected {
color: var(--color-contrast);
.label {
.radio {
color: var(--color-brand);
}
.theme-icon {
color: var(--color-text);
}
}
}
.preview {
background-color: var(--color-bg);
padding: 1.5rem;
outline: 2px solid transparent;
width: 100%;
.example-card {
margin: 0;
padding: 1rem;
outline: 2px solid transparent;
min-height: 0;
}
}
.label {
display: flex;
align-items: center;
text-align: left;
flex-grow: 1;
padding: var(--gap-md) var(--gap-lg);
.radio {
margin-right: 0.5rem;
}
.theme-icon {
color: var(--color-secondary);
margin-left: 0.25rem;
}
}
}
.theme-options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12.5rem, 1fr));
gap: var(--gap-lg);
.preview .example-card {
margin: 0;
padding: 1rem;
display: grid;
grid-template: 'icon text1' 'icon text2';
grid-template-columns: auto 1fr;
gap: 0.5rem;
outline: 2px solid transparent;
.example-icon {
grid-area: icon;
width: 2rem;
height: 2rem;
background-color: var(--color-button-bg);
border-radius: var(--radius-sm);
outline: 2px solid transparent;
}
.example-text-1,
.example-text-2 {
height: 0.5rem;
border-radius: var(--radius-sm);
outline: 2px solid transparent;
}
.example-text-1 {
grid-area: text1;
width: 100%;
background-color: var(--color-base);
}
.example-text-2 {
grid-area: text2;
width: 60%;
background-color: var(--color-secondary);
}
}
}
.project-lists {
display: flex;
flex-direction: column;
gap: var(--gap-md);
> :first-child .label__title {
margin-top: 0;
}
.preview {
--_layout-width: 7rem;
--_layout-height: 4.5rem;
--_layout-gap: 0.25rem;
.example-card {
border-radius: 0.5rem;
width: var(--_layout-width);
height: calc((var(--_layout-height) - 3 * var(--_layout-gap)) / 4);
padding: 0;
}
.layout-list-mode {
display: grid;
grid-template-columns: 1fr;
gap: var(--_layout-gap);
}
.layout-grid-mode {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: var(--_layout-gap);
.example-card {
width: calc((var(--_layout-width) - 2 * var(--_layout-gap)) / 3);
height: calc((var(--_layout-height) - var(--_layout-gap)) / 2);
}
}
.layout-gallery-mode {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--_layout-gap);
.example-card {
width: calc((var(--_layout-width) - var(--_layout-gap)) / 2);
height: calc((var(--_layout-height) - var(--_layout-gap)) / 2);
}
}
}
}
.project-list-layouts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr));
gap: var(--gap-lg);
.preview-radio .example-card {
border: 2px solid transparent;
}
.preview-radio.selected .example-card {
border-color: var(--color-brand);
background-color: var(--color-brand-highlight);
}
.preview {
display: flex;
align-items: center;
justify-content: center;
}
}
.developer-message {
svg {
vertical-align: middle;
margin-bottom: 2px;
margin-right: 0.5rem;
}
.btn {
margin-top: var(--gap-sm);
}
}
</style>

View File

@@ -0,0 +1,527 @@
<script setup lang="ts">
import Fuse from 'fuse.js/dist/fuse.basic'
import RadioButtonIcon from '~/assets/images/utils/radio-button.svg?component'
import RadioButtonCheckedIcon from '~/assets/images/utils/radio-button-checked.svg?component'
import WarningIcon from '~/assets/images/utils/issues.svg?component'
import { isModifierKeyDown } from '~/helpers/events.ts'
import { commonSettingsMessages } from '~/utils/common-messages.ts'
const vintl = useVIntl()
const { formatMessage } = vintl
const messages = defineMessages({
languagesDescription: {
id: 'settings.language.description',
defaultMessage:
'Choose your preferred language for the site. Translations are contributed by volunteers <crowdin-link>on Crowdin</crowdin-link>.',
},
automaticLocale: {
id: 'settings.language.languages.automatic',
defaultMessage: 'Sync with the system language',
},
noResults: {
id: 'settings.language.languages.search.no-results',
defaultMessage: 'No languages match your search.',
},
searchFieldDescription: {
id: 'settings.language.languages.search-field.description',
defaultMessage: 'Submit to focus the first search result',
},
searchFieldPlaceholder: {
id: 'settings.language.languages.search-field.placeholder',
defaultMessage: 'Search for a language...',
},
searchResultsAnnouncement: {
id: 'settings.language.languages.search-results-announcement',
defaultMessage:
'{matches, plural, =0 {No languages match} one {# language matches} other {# languages match}} your search.',
},
loadFailed: {
id: 'settings.language.languages.load-failed',
defaultMessage: 'Cannot load this language. Try again in a bit.',
},
languageLabelApplying: {
id: 'settings.language.languages.language-label-applying',
defaultMessage: '{label}. Applying...',
},
languageLabelError: {
id: 'settings.language.languages.language-label-error',
defaultMessage: '{label}. Error',
},
})
const categoryNames = defineMessages({
auto: {
id: 'settings.language.categories.auto',
defaultMessage: 'Automatic',
},
default: {
id: 'settings.language.categories.default',
defaultMessage: 'Standard languages',
},
fun: {
id: 'settings.language.categories.fun',
defaultMessage: 'Fun languages',
},
experimental: {
id: 'settings.language.categories.experimental',
defaultMessage: 'Experimental languages',
},
searchResult: {
id: 'settings.language.categories.search-result',
defaultMessage: 'Search results',
},
})
type Category = keyof typeof categoryNames
const categoryOrder: Category[] = ['auto', 'default', 'fun', 'experimental']
function normalizeCategoryName(name?: string): keyof typeof categoryNames {
switch (name) {
case 'auto':
case 'fun':
case 'experimental':
return name
default:
return 'default'
}
}
type LocaleBase = {
category: Category
tag: string
searchTerms?: string[]
}
type AutomaticLocale = LocaleBase & {
auto: true
}
type CommonLocale = LocaleBase & {
auto?: never
displayName: string
defaultName: string
translatedName: string
}
type Locale = AutomaticLocale | CommonLocale
const $defaultNames = useDisplayNames(() => vintl.defaultLocale)
const $translatedNames = useDisplayNames(() => vintl.locale)
const $locales = computed(() => {
const locales: Locale[] = []
locales.push({
auto: true,
tag: 'auto',
category: 'auto',
searchTerms: [
'automatic',
'Sync with the system language',
formatMessage(messages.automaticLocale),
],
})
for (const locale of vintl.availableLocales) {
let displayName = locale.meta?.displayName
if (displayName == null) {
displayName = createDisplayNames(locale.tag).of(locale.tag) ?? locale.tag
}
let defaultName = vintl.defaultResources['languages.json']?.[locale.tag]
if (defaultName == null) {
defaultName = $defaultNames.value.of(locale.tag) ?? locale.tag
}
let translatedName = vintl.resources['languages.json']?.[locale.tag]
if (translatedName == null) {
translatedName = $translatedNames.value.of(locale.tag) ?? locale.tag
}
let searchTerms = locale.meta?.searchTerms
if (searchTerms === '-') searchTerms = undefined
locales.push({
tag: locale.tag,
category: normalizeCategoryName(locale.meta?.category),
displayName,
defaultName,
translatedName,
searchTerms: searchTerms?.split('\n'),
})
}
return locales
})
const $query = ref('')
const isQueryEmpty = () => $query.value.trim().length === 0
const fuse = new Fuse<Locale>([], {
keys: ['tag', 'displayName', 'translatedName', 'englishName', 'searchTerms'],
threshold: 0.4,
distance: 100,
})
watchSyncEffect(() => fuse.setCollection($locales.value))
const $categories = computed(() => {
const categories = new Map<Category, Locale[]>()
for (const category of categoryOrder) categories.set(category, [])
for (const locale of $locales.value) {
let categoryLocales = categories.get(locale.category)
if (categoryLocales == null) {
categoryLocales = []
categories.set(locale.category, categoryLocales)
}
categoryLocales.push(locale)
}
for (const categoryKey of [...categories.keys()]) {
if (categories.get(categoryKey)?.length === 0) {
categories.delete(categoryKey)
}
}
return categories
})
const $searchResults = computed(() => {
return new Map<Category, Locale[]>([
['searchResult', isQueryEmpty() ? [] : fuse.search($query.value).map(({ item }) => item)],
])
})
const $displayCategories = computed(() =>
isQueryEmpty() ? $categories.value : $searchResults.value
)
const $changingTo = ref<string | undefined>()
const isChanging = () => $changingTo.value != null
const $failedLocale = ref<string>()
const $activeLocale = computed(() => {
if ($changingTo.value != null) return $changingTo.value
return vintl.automatic ? 'auto' : vintl.locale
})
async function changeLocale(value: string) {
if ($activeLocale.value === value) return
$changingTo.value = value
try {
await vintl.changeLocale(value)
$failedLocale.value = undefined
} catch (err) {
$failedLocale.value = value
} finally {
$changingTo.value = undefined
}
}
const $languagesList = ref<HTMLDivElement | undefined>()
function onSearchKeydown(e: KeyboardEvent) {
if (e.key !== 'Enter' || isModifierKeyDown(e)) return
const focusableTarget = $languagesList.value?.querySelector(
'input, [tabindex]:not([tabindex="-1"])'
) as HTMLElement | undefined
focusableTarget?.focus()
}
function onItemKeydown(e: KeyboardEvent, locale: Locale) {
switch (e.key) {
case 'Enter':
case ' ':
break
default:
return
}
if (isModifierKeyDown(e) || isChanging()) return
changeLocale(locale.tag)
}
function onItemClick(e: MouseEvent, locale: Locale) {
if (isModifierKeyDown(e) || isChanging()) return
changeLocale(locale.tag)
}
function getItemLabel(locale: Locale) {
const label = locale.auto
? formatMessage(messages.automaticLocale)
: `${locale.translatedName}. ${locale.displayName}`
if ($changingTo.value === locale.tag) {
return formatMessage(messages.languageLabelApplying, { label })
}
if ($failedLocale.value === locale.tag) {
return formatMessage(messages.languageLabelError, { label })
}
return label
}
</script>
<template>
<div>
<section class="universal-card">
<h2>{{ formatMessage(commonSettingsMessages.language) }}</h2>
<div class="card-description">
<IntlFormatted :message-id="messages.languagesDescription">
<template #crowdin-link="{ children }">
<a href="https://crowdin.com/project/modrinth">
<component :is="() => children" />
</a>
</template>
</IntlFormatted>
</div>
<div class="search-container">
<input
id="language-search"
v-model="$query"
name="language"
type="search"
:placeholder="formatMessage(messages.searchFieldPlaceholder)"
class="language-search"
aria-describedby="language-search-description"
:disabled="isChanging()"
@keydown="onSearchKeydown"
/>
<div id="language-search-description" class="visually-hidden">
{{ formatMessage(messages.searchFieldDescription) }}
</div>
<div id="language-search-results-announcements" class="visually-hidden" aria-live="polite">
{{
isQueryEmpty()
? ''
: formatMessage(messages.searchResultsAnnouncement, {
matches: $searchResults.get('searchResult')?.length ?? 0,
})
}}
</div>
</div>
<div ref="$languagesList" class="languages-list">
<template v-for="[category, locales] in $displayCategories" :key="category">
<strong class="category-name">
{{ formatMessage(categoryNames[category]) }}
</strong>
<div
v-if="category === 'searchResult' && locales.length === 0"
class="no-results"
tabindex="0"
>
{{ formatMessage(messages.noResults) }}
</div>
<template v-for="locale in locales" :key="locale.tag">
<div
role="button"
:aria-pressed="$activeLocale === locale.tag"
:class="{
'language-item': true,
pending: $changingTo == locale.tag,
errored: $failedLocale == locale.tag,
}"
:aria-describedby="
$failedLocale == locale.tag ? `language__${locale.tag}__fail` : undefined
"
:aria-disabled="isChanging() && $changingTo !== locale.tag"
:tabindex="0"
:aria-label="getItemLabel(locale)"
@click="(e) => onItemClick(e, locale)"
@keydown="(e) => onItemKeydown(e, locale)"
>
<RadioButtonCheckedIcon v-if="$activeLocale === locale.tag" class="radio" />
<RadioButtonIcon v-else class="radio" />
<div class="language-names">
<div class="language-name">
{{ locale.auto ? formatMessage(messages.automaticLocale) : locale.displayName }}
</div>
<div v-if="!locale.auto" class="language-translated-name">
{{ locale.translatedName }}
</div>
</div>
</div>
<div
v-if="$failedLocale === locale.tag"
:id="`language__${locale.tag}__fail`"
class="language-load-error"
>
<WarningIcon /> {{ formatMessage(messages.loadFailed) }}
</div>
</template>
</template>
</div>
</section>
</div>
</template>
<style scoped lang="scss">
.languages-list {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.language-item {
display: flex;
align-items: center;
column-gap: 0.5rem;
border: 0.15rem solid transparent;
border-radius: var(--spacing-card-md);
background: var(--color-button-bg);
padding: var(--spacing-card-md);
cursor: pointer;
position: relative;
overflow: hidden;
&:not([aria-disabled='true']):hover {
border-color: var(--color-button-bg-hover);
}
&:focus-visible,
&:has(:focus-visible) {
outline: 2px solid var(--color-brand);
}
&.errored {
border-color: var(--color-red);
&:hover {
border-color: var(--color-red);
}
}
&.pending::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: linear-gradient(
102deg,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0) 20%,
rgba(0, 0, 0, 0.1) 45%,
rgba(0, 0, 0, 0.1) 50%,
rgba(0, 0, 0, 0) 80%,
rgba(0, 0, 0, 0) 100%
);
background-repeat: no-repeat;
animation: shimmerSliding 2.5s ease-out infinite;
.dark-mode &,
.oled-mode & {
background-image: linear-gradient(
102deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0) 20%,
rgba(255, 255, 255, 0.1) 45%,
rgba(255, 255, 255, 0.1) 50%,
rgba(255, 255, 255, 0) 80%,
rgba(255, 255, 255, 0) 100%
);
}
@keyframes shimmerSliding {
from {
left: -100%;
}
to {
left: 100%;
}
}
}
&[aria-disabled='true']:not(.pending) {
opacity: 0.8;
pointer-events: none;
cursor: default;
}
}
.language-load-error {
color: var(--color-red);
font-size: var(--font-size-sm);
margin-left: 0.3rem;
display: flex;
align-items: center;
gap: 0.3rem;
}
.radio {
width: 24px;
height: 24px;
}
.language-names {
display: flex;
justify-content: space-between;
flex: 1;
flex-wrap: wrap;
}
.language-name {
font-weight: bold;
}
.language-search {
width: 100%;
}
.search-container {
margin-bottom: var(--spacing-card-md);
}
.card-description {
margin-bottom: calc(var(--spacing-card-sm) + var(--spacing-card-md));
a {
color: var(--color-link);
&:hover {
color: var(--color-link-hover);
}
&:active {
color: var(--color-link-active);
}
}
}
.category-name {
margin-top: var(--spacing-card-md);
}
</style>

View File

@@ -0,0 +1,428 @@
<template>
<div class="universal-card">
<ConfirmModal
ref="modal_confirm"
:title="formatMessage(deleteModalMessages.title)"
:description="formatMessage(deleteModalMessages.description)"
:proceed-label="formatMessage(deleteModalMessages.action)"
@proceed="removePat(deletePatIndex)"
/>
<Modal
ref="patModal"
:header="
editPatIndex !== null
? formatMessage(createModalMessages.editTitle)
: formatMessage(createModalMessages.createTitle)
"
>
<div class="universal-modal">
<label for="pat-name">
<span class="label__title">{{ formatMessage(createModalMessages.nameLabel) }}</span>
</label>
<input
id="pat-name"
v-model="name"
maxlength="2048"
type="email"
:placeholder="formatMessage(createModalMessages.namePlaceholder)"
/>
<label for="pat-scopes">
<span class="label__title">{{ formatMessage(commonMessages.scopesLabel) }}</span>
</label>
<div id="pat-scopes" class="checkboxes">
<Checkbox
v-for="scope in scopeList"
:key="scope"
:label="scopesToLabels(getScopeValue(scope)).join(', ')"
:model-value="hasScope(scopesVal, scope)"
@update:model-value="scopesVal = toggleScope(scopesVal, scope)"
/>
</div>
<label for="pat-name">
<span class="label__title">{{ formatMessage(createModalMessages.expiresLabel) }}</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 />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
<button
v-if="editPatIndex !== null"
:disabled="loading || !name || !expires"
type="button"
class="iconified-button brand-button"
@click="editPat"
>
<SaveIcon />
{{ formatMessage(commonMessages.saveChangesButton) }}
</button>
<button
v-else
:disabled="loading || !name || !expires"
type="button"
class="iconified-button brand-button"
@click="createPat"
>
<PlusIcon />
{{ formatMessage(createModalMessages.action) }}
</button>
</div>
</div>
</Modal>
<div class="header__row">
<div class="header__title">
<h2>{{ formatMessage(commonSettingsMessages.pats) }}</h2>
</div>
<button
class="btn btn-primary"
@click="
() => {
name = null
scopesVal = 0
expires = null
editPatIndex = null
$refs.patModal.show()
}
"
>
<PlusIcon /> {{ formatMessage(messages.create) }}
</button>
</div>
<p>
<IntlFormatted :message-id="messages.description">
<template #doc-link="{ children }">
<a class="text-link" href="https://docs.modrinth.com">
<component :is="() => children" />
</a>
</template>
</IntlFormatted>
</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
? formatMessage(commonMessages.dateAtTimeTooltip, {
date: new Date(pat.last_used),
time: new Date(pat.last_used),
})
: null
"
>
<template v-if="pat.last_used">
{{
formatMessage(tokenMessages.lastUsed, {
ago: formatRelativeTime(pat.last_used),
})
}}
</template>
<template v-else>{{ formatMessage(tokenMessages.neverUsed) }}</template>
</span>
<span
v-tooltip="
formatMessage(commonMessages.dateAtTimeTooltip, {
date: new Date(pat.expires),
time: new Date(pat.expires),
})
"
>
<template v-if="new Date(pat.expires) > new Date()">
{{
formatMessage(tokenMessages.expiresIn, {
inTime: formatRelativeTime(pat.expires),
})
}}
</template>
<template v-else>
{{
formatMessage(tokenMessages.expiredAgo, {
ago: formatRelativeTime(pat.expires),
})
}}
</template>
</span>
<span
v-tooltip="
formatMessage(commonMessages.dateAtTimeTooltip, {
date: new Date(pat.created),
time: new Date(pat.created),
})
"
>
{{
formatMessage(commonMessages.createdAgoLabel, {
ago: formatRelativeTime(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 /> {{ formatMessage(tokenMessages.edit) }}
</button>
<button
class="iconified-button raised-button"
@click="
() => {
deletePatIndex = pat.id
$refs.modal_confirm.show()
}
"
>
<TrashIcon /> {{ formatMessage(tokenMessages.revoke) }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { PlusIcon, XIcon, TrashIcon, EditIcon, SaveIcon } from '@modrinth/assets'
import { Checkbox, ConfirmModal } from '@modrinth/ui'
import { commonSettingsMessages } from '~/utils/common-messages.ts'
import {
hasScope,
scopeList,
toggleScope,
useScopes,
getScopeValue,
} from '~/composables/auth/scopes.ts'
import CopyCode from '~/components/ui/CopyCode.vue'
import Modal from '~/components/ui/Modal.vue'
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const createModalMessages = defineMessages({
createTitle: {
id: 'settings.pats.modal.create.title',
defaultMessage: 'Create personal access token',
},
editTitle: {
id: 'settings.pats.modal.edit.title',
defaultMessage: 'Edit personal access token',
},
nameLabel: {
id: 'settings.pats.modal.create.name.label',
defaultMessage: 'Name',
},
namePlaceholder: {
id: 'settings.pats.modal.create.name.placeholder',
defaultMessage: "Enter the PAT's name...",
},
expiresLabel: {
id: 'settings.pats.modal.create.expires.label',
defaultMessage: 'Expires',
},
action: {
id: 'settings.pats.modal.create.action',
defaultMessage: 'Create PAT',
},
})
const deleteModalMessages = defineMessages({
title: {
id: 'settings.pats.modal.delete.title',
defaultMessage: 'Are you sure you want to delete this token?',
},
description: {
id: 'settings.pats.modal.delete.description',
defaultMessage: 'This will remove this token forever (like really forever).',
},
action: {
id: 'settings.pats.modal.delete.action',
defaultMessage: 'Delete this token',
},
})
const messages = defineMessages({
description: {
id: 'settings.pats.description',
defaultMessage:
"PATs can be used to access Modrinth's API. For more information, see <doc-link>Modrinth's API documentation</doc-link>. They can be created and revoked at any time.",
},
create: {
id: 'settings.pats.action.create',
defaultMessage: 'Create a PAT',
},
})
const tokenMessages = defineMessages({
edit: {
id: 'settings.pats.token.action.edit',
defaultMessage: 'Edit token',
},
revoke: {
id: 'settings.pats.token.action.revoke',
defaultMessage: 'Revoke token',
},
lastUsed: {
id: 'settings.pats.token.last-used',
defaultMessage: 'Last used {ago}',
},
neverUsed: {
id: 'settings.pats.token.never-used',
defaultMessage: 'Never used',
},
expiresIn: {
id: 'settings.pats.token.expires-in',
defaultMessage: 'Expires {inTime}',
},
expiredAgo: {
id: 'settings.pats.token.expired-ago',
defaultMessage: 'Expired {ago}',
},
})
definePageMeta({
middleware: 'auth',
})
useHead({
title: `${formatMessage(commonSettingsMessages.pats)} - Modrinth`,
})
const data = useNuxtApp()
const { scopesToLabels } = useScopes()
const patModal = ref()
const editPatIndex = ref(null)
const name = ref(null)
const scopesVal = ref(BigInt(0))
const expires = ref(null)
const deletePatIndex = 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: Number(scopesVal.value),
expires: data.$dayjs(expires.value).toISOString(),
},
})
pats.value.push(res)
patModal.value.hide()
} catch (err) {
data.$notify({
group: 'main',
title: formatMessage(commonMessages.errorNotificationTitle),
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: Number(scopesVal.value),
expires: data.$dayjs(expires.value).toISOString(),
},
})
await refresh()
patModal.value.hide()
} catch (err) {
data.$notify({
group: 'main',
title: formatMessage(commonMessages.errorNotificationTitle),
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: formatMessage(commonMessages.errorNotificationTitle),
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>

View File

@@ -0,0 +1,234 @@
<template>
<div>
<section class="card">
<h2>{{ formatMessage(messages.title) }}</h2>
<p>
<IntlFormatted :message-id="messages.description">
<template #docs-link="{ children }">
<a href="https://docs.modrinth.com/" target="_blank" class="text-link">
<component :is="() => children" />
</a>
</template>
</IntlFormatted>
</p>
<label>
<span class="label__title">{{ formatMessage(messages.profilePicture) }}</span>
</label>
<div class="avatar-changer">
<Avatar
:src="previewImage ? previewImage : avatarUrl"
size="md"
circle
:alt="auth.user.username"
/>
<div class="input-stack">
<FileInput
:max-size="262144"
:show-icon="true"
class="btn"
:prompt="formatMessage(commonMessages.uploadImageButton)"
accept="image/png,image/jpeg,image/gif,image/webp"
@change="showPreviewImage"
>
<UploadIcon />
</FileInput>
<Button
v-if="previewImage"
:action="
() => {
icon = null
previewImage = null
}
"
>
<UndoIcon />
{{ formatMessage(messages.profilePictureReset) }}
</Button>
</div>
</div>
<label for="username-field">
<span class="label__title">{{ formatMessage(messages.usernameTitle) }}</span>
<span class="label__description">
{{ formatMessage(messages.usernameDescription) }}
</span>
</label>
<input id="username-field" v-model="username" type="text" />
<label for="bio-field">
<span class="label__title">{{ formatMessage(messages.bioTitle) }}</span>
<span class="label__description">
{{ formatMessage(messages.bioDescription) }}
</span>
</label>
<textarea id="bio-field" v-model="bio" type="text" />
<div v-if="hasUnsavedChanges" class="input-group">
<Button color="primary" :action="() => saveChanges()">
<SaveIcon /> {{ formatMessage(commonMessages.saveChangesButton) }}
</Button>
<Button :action="() => cancel()">
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
</Button>
</div>
<div v-else class="input-group">
<Button disabled color="primary" :action="() => saveChanges()">
<SaveIcon />
{{
saved
? formatMessage(commonMessages.changesSavedLabel)
: formatMessage(commonMessages.saveChangesButton)
}}
</Button>
<Button :link="`/user/${auth.user.username}`">
<UserIcon /> {{ formatMessage(commonMessages.visitYourProfile) }}
</Button>
</div>
</section>
</div>
</template>
<script setup>
import { UserIcon, SaveIcon, UploadIcon, UndoIcon, XIcon } from '@modrinth/assets'
import { Avatar, FileInput, Button } from '@modrinth/ui'
import { commonMessages } from '~/utils/common-messages.ts'
useHead({
title: 'Profile settings - Modrinth',
})
definePageMeta({
middleware: 'auth',
})
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: {
id: 'settings.profile.profile-info',
defaultMessage: 'Profile information',
},
description: {
id: 'settings.profile.description',
defaultMessage:
'Your profile information is publicly viewable on Modrinth and through the <docs-link>Modrinth API</docs-link>.',
},
profilePicture: {
id: 'settings.profile.profile-picture.title',
defaultMessage: 'Profile picture',
},
profilePictureReset: {
id: 'settings.profile.profile-picture.reset',
defaultMessage: 'Reset',
},
usernameTitle: {
id: 'settings.profile.username.title',
defaultMessage: 'Username',
},
usernameDescription: {
id: 'settings.profile.username.description',
defaultMessage: 'A unique case-insensitive name to identify your profile.',
},
bioTitle: {
id: 'settings.profile.bio.title',
defaultMessage: 'Bio',
},
bioDescription: {
id: 'settings.profile.bio.description',
defaultMessage: 'A short description to tell everyone a little bit about you.',
},
})
const auth = await useAuth()
const username = ref(auth.value.user.username)
const bio = ref(auth.value.user.bio)
const avatarUrl = ref(auth.value.user.avatar_url)
const icon = shallowRef(null)
const previewImage = shallowRef(null)
const saved = ref(false)
const hasUnsavedChanges = computed(
() =>
username.value !== auth.value.user.username ||
bio.value !== auth.value.user.bio ||
previewImage.value
)
function showPreviewImage(files) {
const reader = new FileReader()
icon.value = files[0]
reader.readAsDataURL(icon.value)
reader.onload = (event) => {
previewImage.value = event.target.result
}
}
function cancel() {
icon.value = null
previewImage.value = null
username.value = auth.value.user.username
bio.value = auth.value.user.bio
}
async function saveChanges() {
startLoading()
try {
if (icon.value) {
await useBaseFetch(
`user/${auth.value.user.id}/icon?ext=${
icon.value.type.split('/')[icon.value.type.split('/').length - 1]
}`,
{
method: 'PATCH',
body: icon.value,
}
)
icon.value = null
previewImage.value = null
}
const body = {}
if (auth.value.user.username !== username.value) {
body.username = username.value
}
if (auth.value.user.bio !== bio.value) {
body.bio = bio.value
}
await useBaseFetch(`user/${auth.value.user.id}`, {
method: 'PATCH',
body,
})
await useAuth(auth.value.token)
avatarUrl.value = auth.value.user.avatar_url
saved.value = true
} catch (err) {
addNotification({
group: 'main',
title: 'An error occurred',
text: err
? err.data
? err.data.description
? err.data.description
: err.data
: err
: 'aaaaahhh',
type: 'error',
})
}
stopLoading()
}
</script>
<style lang="scss" scoped>
.avatar-changer {
display: flex;
gap: var(--gap-lg);
margin-top: var(--gap-md);
}
textarea {
height: 6rem;
width: 40rem;
margin-bottom: var(--gap-lg);
}
</style>

View File

@@ -0,0 +1,144 @@
<template>
<div class="universal-card">
<h2>{{ formatMessage(commonSettingsMessages.sessions) }}</h2>
<p class="preserve-lines">
{{ formatMessage(messages.sessionsDescription) }}
</p>
<div v-for="session in sessions" :key="session.id" class="universal-card recessed session">
<div>
<div>
<strong>
{{ session.os ?? formatMessage(messages.unknownOsLabel) }}
{{ session.platform ?? formatMessage(messages.unknownPlatformLabel) }}
{{ session.ip }}
</strong>
</div>
<div>
<template v-if="session.city">{{ session.city }}, {{ session.country }} </template>
<span
v-tooltip="
formatMessage(commonMessages.dateAtTimeTooltip, {
date: new Date(session.last_login),
time: new Date(session.last_login),
})
"
>
{{
formatMessage(messages.lastAccessedAgoLabel, {
ago: formatRelativeTime(session.last_login),
})
}}
</span>
<span
v-tooltip="
formatMessage(commonMessages.dateAtTimeTooltip, {
date: new Date(session.created),
time: new Date(session.created),
})
"
>
{{
formatMessage(messages.createdAgoLabel, {
ago: formatRelativeTime(session.created),
})
}}
</span>
</div>
</div>
<div class="input-group">
<i v-if="session.current">{{ formatMessage(messages.currentSessionLabel) }}</i>
<button v-else class="iconified-button raised-button" @click="revokeSession(session.id)">
<XIcon /> {{ formatMessage(messages.revokeSessionButton) }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { XIcon } from '@modrinth/assets'
import { commonSettingsMessages } from '~/utils/common-messages.ts'
definePageMeta({
middleware: 'auth',
})
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const messages = defineMessages({
currentSessionLabel: {
id: 'settings.sessions.current-session',
defaultMessage: 'Current session',
},
revokeSessionButton: {
id: 'settings.sessions.action.revoke-session',
defaultMessage: 'Revoke session',
},
createdAgoLabel: {
id: 'settings.sessions.created-ago',
defaultMessage: 'Created {ago}',
},
sessionsDescription: {
id: 'settings.sessions.description',
defaultMessage:
"Here are all the devices that are currently logged in with your Modrinth account. You can log out of each one individually.\n\nIf you see an entry you don't recognize, log out of that device and change your Modrinth account password immediately.",
},
lastAccessedAgoLabel: {
id: 'settings.sessions.last-accessed-ago',
defaultMessage: 'Last accessed {ago}',
},
unknownOsLabel: {
id: 'settings.sessions.unknown-os',
defaultMessage: 'Unknown OS',
},
unknownPlatformLabel: {
id: 'settings.sessions.unknown-platform',
defaultMessage: 'Unknown platform',
},
})
useHead({
title: () => `${formatMessage(commonSettingsMessages.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: formatMessage(commonMessages.errorNotificationTitle),
text: err.data.description,
type: 'error',
})
}
stopLoading()
}
</script>
<style lang="scss" scoped>
.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>