mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
184
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2643e6ef1a | ||
|
|
8215129e55 | ||
|
|
21fdd30919 | ||
|
|
7fcf5b8c82 | ||
|
|
2f607774e7 | ||
|
|
a713583b83 | ||
|
|
4d736e71d3 | ||
|
|
f6f2cb8d8b | ||
|
|
079381246b | ||
|
|
fadd3cf7f8 | ||
|
|
b24a45ba8c | ||
|
|
4b2b5a56c1 | ||
|
|
5aafa40f31 | ||
|
|
5b80b71be4 | ||
|
|
f9f569bec6 | ||
|
|
1943fe4f80 | ||
|
|
992c206200 | ||
|
|
1a0633b6db | ||
|
|
2b38ea6076 | ||
|
|
2a873e4500 | ||
|
|
3ae50a2abb | ||
|
|
408927a23e | ||
|
|
b2408eec0d | ||
|
|
895e7aabc6 | ||
|
|
3970b4e3ae | ||
|
|
722e35fe28 | ||
|
|
7e54d9df9f | ||
|
|
5fc8171711 | ||
|
|
4943d71b68 | ||
|
|
7f972c4bc3 | ||
|
|
5050da81e0 | ||
|
|
a6be110734 | ||
|
|
f135f121e3 | ||
|
|
9834e86d4f | ||
|
|
471b67fe5a | ||
|
|
ed4d7594d7 | ||
|
|
cccaf1eee6 | ||
|
|
e2f2fd8e65 | ||
|
|
888d70f629 | ||
|
|
04bdbd2d0a | ||
|
|
78b617c25c | ||
|
|
dc1c3c8662 | ||
|
|
2175be5bda | ||
|
|
edb82b5304 | ||
|
|
4653b0c803 | ||
|
|
7a420f03f4 | ||
|
|
ecd77812ce | ||
|
|
8bc26f2cf1 | ||
|
|
99c77d756e | ||
|
|
af2224d44b | ||
|
|
c8b64df900 | ||
|
|
8658dbf3db | ||
|
|
e8114a3614 | ||
|
|
3cd1a06243 | ||
|
|
145c79fa51 | ||
|
|
690f5c148b | ||
|
|
cee57ffe6e | ||
|
|
84bcf366b1 | ||
|
|
2fecbe9c95 | ||
|
|
578b8df720 | ||
|
|
611e308134 | ||
|
|
549e9d5554 | ||
|
|
25c63b542c | ||
|
|
61209ac0e1 | ||
|
|
3c29a62036 | ||
|
|
9071f7dee7 | ||
|
|
50eaecf86d | ||
|
|
6de97cc0a8 | ||
|
|
0bfa16295d | ||
|
|
5d7a77cdfc | ||
|
|
876a4b649d | ||
|
|
e16884aec5 | ||
|
|
e11cd519cd | ||
|
|
9ae1ddc5ab | ||
|
|
cf02d151cc | ||
|
|
61ff32a58b | ||
|
|
8d82bf38b1 | ||
|
|
2d3b4ab54c | ||
|
|
3eb76da8af | ||
|
|
00993a3ff8 | ||
|
|
1f28196649 | ||
|
|
030dc2b3f7 | ||
|
|
3801039771 | ||
|
|
c75e17ec0e | ||
|
|
7315c35a41 | ||
|
|
407aa4b4cf | ||
|
|
d709bbeb54 | ||
|
|
b5662371d8 | ||
|
|
db9689b335 | ||
|
|
0f1483d9df | ||
|
|
d48ad873fb | ||
|
|
ff7c0cb5b8 | ||
|
|
faed8c3f6d | ||
|
|
63d00cb660 | ||
|
|
27d54a98af | ||
|
|
c07b7afef6 | ||
|
|
0d488d162e | ||
|
|
867aadc2e5 | ||
|
|
b6b5271e11 | ||
|
|
2ee217bdff | ||
|
|
9819611217 | ||
|
|
1fdaf8e001 | ||
|
|
84888dc788 | ||
|
|
91d74e7705 | ||
|
|
a0471dad2b | ||
|
|
bfd592487e | ||
|
|
d53d5c4538 | ||
|
|
e30cf265a4 | ||
|
|
1b26f55f1e | ||
|
|
d983f115ed | ||
|
|
f01d149603 | ||
|
|
b93f6c682c | ||
|
|
220733fa2f | ||
|
|
e33b1dcec5 | ||
|
|
796049f6b0 | ||
|
|
05a1a10788 | ||
|
|
addb6c6045 | ||
|
|
e08da23fb5 | ||
|
|
ecd69f88b2 | ||
|
|
d834ce3645 | ||
|
|
cf58559381 | ||
|
|
01a7ee8d1e | ||
|
|
5480826a53 | ||
|
|
9184de92df | ||
|
|
d753b8735c | ||
|
|
c7a5336a51 | ||
|
|
292b8f4cd2 | ||
|
|
c391fb12b9 | ||
|
|
62f20b9aee | ||
|
|
2aca44bbcc | ||
|
|
b9be6abf0b | ||
|
|
a5be0114c5 | ||
|
|
f32f732c31 | ||
|
|
e974672e71 | ||
|
|
d6356ea8e5 | ||
|
|
872dcbcf78 | ||
|
|
44da0ed5ff | ||
|
|
ec7c893681 | ||
|
|
3ce8f85463 | ||
|
|
4ce82b01e6 | ||
|
|
15f67eae3d | ||
|
|
d846ef7f7d | ||
|
|
8d4076f8f0 | ||
|
|
bc9e586092 | ||
|
|
e1f44b861e | ||
|
|
81abcf6403 | ||
|
|
873a12bae5 | ||
|
|
2d2598b221 | ||
|
|
804f6ca031 | ||
|
|
fd28721d35 | ||
|
|
f4d65437df | ||
|
|
9646083025 | ||
|
|
652a9b0b5c | ||
|
|
918c4a9c83 | ||
|
|
0a0b19233d | ||
|
|
6f2e04c3de | ||
|
|
ece35f9158 | ||
|
|
3ccb8132d8 | ||
|
|
cad6bd4f92 | ||
|
|
65bd8937a6 | ||
|
|
abcac5f40a | ||
|
|
43893158ac | ||
|
|
fcab720943 | ||
|
|
4183702a72 | ||
|
|
8946e4c394 | ||
|
|
f1ce31001a | ||
|
|
c99564f8ea | ||
|
|
71c1deb5cd | ||
|
|
042dde075b | ||
|
|
3bfe670d47 | ||
|
|
ed53fe5330 | ||
|
|
547921badc | ||
|
|
aac61dcfaa | ||
|
|
52a972d949 | ||
|
|
bdc2b4abf8 | ||
|
|
c19ce11ea4 | ||
|
|
c54906e6de | ||
|
|
57b4f21080 | ||
|
|
66f3c39c13 | ||
|
|
7484afa18e | ||
|
|
7ea0635d86 | ||
|
|
fe3aba52ab | ||
|
|
64f87551ff | ||
|
|
c8a586c6f1 |
@@ -81,6 +81,6 @@ Please follow these rules precisely:
|
||||
Use existing patterns from our codebase:
|
||||
|
||||
- Variables/plurals: see `apps/frontend/src/pages/frog.vue`
|
||||
- Rich-text link tags: see `apps/frontend/src/pages/auth/welcome.vue` and `apps/frontend/src/error.vue`
|
||||
- Rich-text link tags: see `apps/frontend/src/error.vue`
|
||||
|
||||
When you finish, there should be no hard-coded English strings left in the template—everything comes from `formatMessage` or `<IntlFormatted>`.
|
||||
|
||||
@@ -270,6 +270,7 @@ redundant_type_annotations = "warn"
|
||||
result_large_err = "allow"
|
||||
todo = "warn"
|
||||
too_many_arguments = "allow"
|
||||
type_complexity = "allow"
|
||||
uninlined_format_args = "warn"
|
||||
unnested_or_patterns = "warn"
|
||||
wildcard_dependencies = "warn"
|
||||
|
||||
@@ -14,6 +14,8 @@ import { I18nDebugPanel, LoadingBar, NotificationPanel } from '@modrinth/ui'
|
||||
|
||||
import { setupProviders } from '~/providers/setup.ts'
|
||||
|
||||
import { useAuth } from './composables/auth'
|
||||
|
||||
const auth = await useAuth()
|
||||
setupProviders(auth)
|
||||
</script>
|
||||
|
||||
@@ -756,16 +756,16 @@ svg.inline-svg {
|
||||
padding: var(--gap-16);
|
||||
|
||||
h2 {
|
||||
@apply font-semibold;
|
||||
font-size: var(--text-18);
|
||||
font-weight: var(--weight-extrabold);
|
||||
color: var(--color-contrast);
|
||||
line-height: initial;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@apply font-semibold;
|
||||
font-size: var(--text-16);
|
||||
font-weight: var(--weight-bold);
|
||||
color: var(--color-base);
|
||||
margin: 0;
|
||||
}
|
||||
@@ -812,6 +812,7 @@ svg.inline-svg {
|
||||
}
|
||||
|
||||
.details-list__item {
|
||||
@apply font-normal;
|
||||
@extend .iconified-list-item;
|
||||
|
||||
.details-list__item__text--style-secondary {
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
<template>
|
||||
<div
|
||||
class="shadow-card mx-auto flex w-full max-w-[28rem] flex-col gap-6 rounded-2xl border border-button-bg bg-surface-3 p-6"
|
||||
>
|
||||
<h1
|
||||
class="mx-auto my-0 flex w-full justify-center text-center text-2xl font-semibold text-contrast"
|
||||
>
|
||||
{{ formatMessage(messages.title) }}
|
||||
</h1>
|
||||
|
||||
<section v-if="requiresDob" class="flex flex-col gap-2.5">
|
||||
<label class="text-md font-semibold text-contrast" for="create-account-dob">
|
||||
{{ formatMessage(messages.dateOfBirthLabel) }}
|
||||
</label>
|
||||
<DatePicker
|
||||
id="create-account-dob"
|
||||
v-model="dateOfBirthModel"
|
||||
wrapper-class="w-full"
|
||||
min-date="1900-01-01"
|
||||
:max-date="maxInputDate"
|
||||
:default-view-date="defaultDobViewDate"
|
||||
preserve-day
|
||||
:placeholder="formatMessage(messages.dateOfBirthPlaceholder)"
|
||||
/>
|
||||
<div>
|
||||
{{ formatMessage(messages.over13HelperText) }}
|
||||
</div>
|
||||
<Admonition :type="'info'">
|
||||
<template #header>
|
||||
<div class="-mb-3 -mt-1 flex flex-col gap-0 text-sm font-normal leading-normal">
|
||||
<div>
|
||||
{{ formatMessage(messages.infoPanelText) }}
|
||||
</div>
|
||||
<a
|
||||
class="w-fit text-link underline"
|
||||
:href="SOURCE_CODE_URL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ formatMessage(messages.relevantSourceCodeText) }}
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</Admonition>
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-2.5">
|
||||
<label class="text-md font-semibold text-contrast" for="create-account-username">
|
||||
{{ formatMessage(messages.usernameOptionalLabel) }}
|
||||
<span class="font-normal text-primary">(optional)</span>
|
||||
</label>
|
||||
<StyledInput
|
||||
id="create-account-username"
|
||||
v-model="usernameModel"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.usernamePlaceholder)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section v-if="globals?.captcha_enabled" class="flex flex-col gap-2.5">
|
||||
<label class="text-md font-semibold text-contrast">{{
|
||||
formatMessage(messages.securityCheckLabel)
|
||||
}}</label>
|
||||
<HCaptcha v-if="globals?.captcha_enabled" :ref="onSetCaptchaRef" v-model="tokenModel" />
|
||||
</section>
|
||||
|
||||
<div
|
||||
class="flex gap-2.5 rounded-2xl border border-solid border-surface-5 bg-surface-3 transition-all hover:brightness-110"
|
||||
>
|
||||
<Checkbox
|
||||
v-model="subscribeModel"
|
||||
class="p-3 text-left leading-snug text-primary transition-all"
|
||||
:label="formatMessage(messages.subscribeLabel)"
|
||||
:description="formatMessage(messages.subscribeLabel)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
class="!w-full font-bold"
|
||||
:disabled="globals?.captcha_enabled ? !tokenModel : false"
|
||||
@click="onCompleteSignUpClick"
|
||||
>
|
||||
{{ formatMessage(messages.completeSignUpButton) }}
|
||||
<RightArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RightArrowIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import HCaptcha from '@/components/ui/auth/HCaptcha.vue'
|
||||
|
||||
interface AuthGlobals {
|
||||
captcha_enabled?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface Props {
|
||||
globals?: AuthGlobals | null
|
||||
requiresDob?: boolean
|
||||
onCompleteSignUp?: () => void
|
||||
onSetCaptchaRef?: ((captchaRef: unknown) => void) | undefined
|
||||
}
|
||||
|
||||
const {
|
||||
globals = null,
|
||||
requiresDob = true,
|
||||
onCompleteSignUp = () => {},
|
||||
onSetCaptchaRef = undefined,
|
||||
} = defineProps<Props>()
|
||||
|
||||
const SOURCE_CODE_URL =
|
||||
'https://github.com/modrinth/code/blob/main/apps/frontend/src/components/ui/auth/CreateAccount.vue'
|
||||
|
||||
const dateOfBirthModel = defineModel<string | null>('dateOfBirth', { default: '' })
|
||||
const usernameModel = defineModel<string>('username', { default: '' })
|
||||
const tokenModel = defineModel<string>('token', { default: '' })
|
||||
const subscribeModel = defineModel<boolean>('subscribe', { default: false })
|
||||
|
||||
const maxInputDate = computed(() => `${new Date().getFullYear()}-12-31`)
|
||||
|
||||
const defaultDobViewDate = computed(() => `${new Date().getFullYear()}-01-01`)
|
||||
|
||||
const maxBirthDate = computed(() => {
|
||||
const date = new Date()
|
||||
date.setFullYear(date.getFullYear() - 13)
|
||||
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
|
||||
return `${year}-${month}-${day}`
|
||||
})
|
||||
|
||||
const getBirthYear = (dateOfBirth: string | null): number | null => {
|
||||
if (!dateOfBirth) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [yearPart = ''] = dateOfBirth.split('-')
|
||||
const year = Number(yearPart)
|
||||
return Number.isInteger(year) ? year : null
|
||||
}
|
||||
|
||||
const isDateOfBirthMissing = computed(() => requiresDob && !dateOfBirthModel.value)
|
||||
|
||||
const isDateOfBirthYearZero = computed(() => {
|
||||
if (!requiresDob || !dateOfBirthModel.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
return getBirthYear(dateOfBirthModel.value) === 0
|
||||
})
|
||||
|
||||
const isUnder13 = computed(
|
||||
() => requiresDob && !!dateOfBirthModel.value && dateOfBirthModel.value > maxBirthDate.value,
|
||||
)
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
function onCompleteSignUpClick() {
|
||||
if (isDateOfBirthMissing.value) {
|
||||
addNotification({
|
||||
title: formatMessage(messages.dateOfBirthRequiredTitle),
|
||||
text: formatMessage(messages.dateOfBirthRequiredText),
|
||||
type: 'warning',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (isDateOfBirthYearZero.value) {
|
||||
addNotification({
|
||||
title: formatMessage(messages.dateOfBirthInvalidTitle),
|
||||
text: formatMessage(messages.dateOfBirthInvalidText),
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (isUnder13.value) {
|
||||
addNotification({
|
||||
title: formatMessage(messages.ageRequirementWarningTitle),
|
||||
text: formatMessage(messages.under13HelperText),
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
onCompleteSignUp()
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'auth.create-account.title',
|
||||
defaultMessage: 'Create an Account',
|
||||
},
|
||||
dateOfBirthLabel: {
|
||||
id: 'auth.create-account.date-of-birth.label',
|
||||
defaultMessage: 'Date of birth',
|
||||
},
|
||||
dateOfBirthPlaceholder: {
|
||||
id: 'auth.create-account.date-of-birth.placeholder',
|
||||
defaultMessage: 'Select your date of birth',
|
||||
},
|
||||
dateOfBirthRequiredTitle: {
|
||||
id: 'auth.create-account.date-of-birth.required.title',
|
||||
defaultMessage: 'Date of birth required',
|
||||
},
|
||||
dateOfBirthRequiredText: {
|
||||
id: 'auth.create-account.date-of-birth.required.text',
|
||||
defaultMessage: 'Please enter your date of birth before continuing.',
|
||||
},
|
||||
dateOfBirthInvalidTitle: {
|
||||
id: 'auth.create-account.date-of-birth.invalid.title',
|
||||
defaultMessage: 'Invalid date of birth',
|
||||
},
|
||||
dateOfBirthInvalidText: {
|
||||
id: 'auth.create-account.date-of-birth.invalid.text',
|
||||
defaultMessage: 'Please enter a valid date of birth. Year cannot be 0000.',
|
||||
},
|
||||
over13HelperText: {
|
||||
id: 'auth.create-account.date-of-birth.over13-helper',
|
||||
defaultMessage: 'You must be over 13 years old to use Modrinth.',
|
||||
},
|
||||
under13HelperText: {
|
||||
id: 'auth.create-account.date-of-birth.under13-helper',
|
||||
defaultMessage: 'You cannot create an account at Modrinth unless you are over 13 years old.',
|
||||
},
|
||||
ageRequirementWarningTitle: {
|
||||
id: 'auth.create-account.age-requirement.warning-title',
|
||||
defaultMessage: 'Age requirement',
|
||||
},
|
||||
infoPanelText: {
|
||||
id: 'auth.create-account.info-panel.text',
|
||||
defaultMessage:
|
||||
'We do not store your date of birth, only whether you are over 13 to use Modrinth.',
|
||||
},
|
||||
relevantSourceCodeText: {
|
||||
id: 'auth.create-account.info-panel.source-code-link',
|
||||
defaultMessage: 'Relevant source code',
|
||||
},
|
||||
usernameOptionalLabel: {
|
||||
id: 'auth.create-account.username.optional-label',
|
||||
defaultMessage: 'Username',
|
||||
},
|
||||
usernamePlaceholder: {
|
||||
id: 'auth.create-account.username.placeholder',
|
||||
defaultMessage: 'Enter username',
|
||||
},
|
||||
securityCheckLabel: {
|
||||
id: 'auth.create-account.security-check.label',
|
||||
defaultMessage: 'Security check',
|
||||
},
|
||||
subscribeLabel: {
|
||||
id: 'auth.create-account.subscribe.label',
|
||||
defaultMessage: 'Keep me updated on the cool things Modrinth is working on via email',
|
||||
},
|
||||
completeSignUpButton: {
|
||||
id: 'auth.create-account.complete-sign-up',
|
||||
defaultMessage: 'Complete sign up',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
+21
-4
@@ -47,17 +47,34 @@ defineExpose({
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
<style>
|
||||
.h-captcha {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md);
|
||||
border: 2px solid var(--color-button-bg);
|
||||
height: 78px;
|
||||
height: 100px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
iframe {
|
||||
margin: -1px;
|
||||
.h-captcha iframe {
|
||||
transform: scale(1.33);
|
||||
transform-origin: center;
|
||||
margin: -1px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 400px) {
|
||||
.h-captcha {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.h-captcha iframe {
|
||||
transform: scale(1.03);
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,383 @@
|
||||
<template>
|
||||
<div v-if="subtleLauncherRedirectUri">
|
||||
<iframe
|
||||
:src="subtleLauncherRedirectUri"
|
||||
class="fixed left-0 top-0 z-[9999] m-0 h-full w-full border-0 p-0"
|
||||
></iframe>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="universal-card mx-auto flex w-full max-w-[28rem] flex-col gap-6 border border-solid border-surface-5"
|
||||
>
|
||||
<template v-if="flow && !subtleLauncherRedirectUri">
|
||||
<div class="flex flex-col items-end gap-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="two-factor-code">
|
||||
<span class="label__title">{{ formatMessage(messages.twoFactorCodeLabel) }}</span>
|
||||
<span class="label__description">
|
||||
{{ formatMessage(messages.twoFactorCodeLabelDescription) }}
|
||||
</span>
|
||||
</label>
|
||||
<StyledInput
|
||||
id="two-factor-code"
|
||||
v-model="twoFactorCodeModel"
|
||||
:maxlength="11"
|
||||
inputmode="numeric"
|
||||
:placeholder="formatMessage(messages.twoFactorCodeInputPlaceholder)"
|
||||
autocomplete="one-time-code"
|
||||
@keyup.enter="onTwoFactorSignIn()"
|
||||
/>
|
||||
</div>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="onTwoFactorSignIn()">
|
||||
{{ formatMessage(commonMessages.signInButton) }} <RightArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="text-center text-2xl font-semibold text-contrast">
|
||||
{{ formatMessage(messages.signInWithLabel) }}
|
||||
</div>
|
||||
|
||||
<section class="flex flex-col gap-2.5">
|
||||
<ButtonStyled>
|
||||
<a
|
||||
class="oauth-provider-link !shadow-none"
|
||||
:class="{ 'oauth-provider-link--last': lastSignInProvider === 'google' }"
|
||||
:href="getAuthUrl('google', redirectTarget)"
|
||||
@click="onOAuthProviderClick('google')"
|
||||
>
|
||||
<GoogleColorIcon />
|
||||
<span class="ml-1">{{
|
||||
formatMessage(messages.continueWithProvider, { provider: 'Google' })
|
||||
}}</span>
|
||||
<span
|
||||
v-if="lastSignInProvider === 'google'"
|
||||
class="oauth-provider-last-sign-in-badge"
|
||||
>
|
||||
{{ formatMessage(messages.lastSignInLabel) }}
|
||||
</span>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<a
|
||||
class="oauth-provider-link !shadow-none"
|
||||
:class="{ 'oauth-provider-link--last': lastSignInProvider === 'microsoft' }"
|
||||
:href="getAuthUrl('microsoft', redirectTarget)"
|
||||
@click="onOAuthProviderClick('microsoft')"
|
||||
>
|
||||
<MicrosoftColorIcon />
|
||||
<span class="ml-1">{{
|
||||
formatMessage(messages.continueWithProvider, { provider: 'Microsoft' })
|
||||
}}</span>
|
||||
<span
|
||||
v-if="lastSignInProvider === 'microsoft'"
|
||||
class="oauth-provider-last-sign-in-badge"
|
||||
>
|
||||
{{ formatMessage(messages.lastSignInLabel) }}
|
||||
</span>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<a
|
||||
class="oauth-provider-link !shadow-none"
|
||||
:class="{ 'oauth-provider-link--last': lastSignInProvider === 'discord' }"
|
||||
:href="getAuthUrl('discord', redirectTarget)"
|
||||
@click="onOAuthProviderClick('discord')"
|
||||
>
|
||||
<DiscordColorIcon />
|
||||
<span class="ml-1">{{
|
||||
formatMessage(messages.continueWithProvider, { provider: 'Discord' })
|
||||
}}</span>
|
||||
<span
|
||||
v-if="lastSignInProvider === 'discord'"
|
||||
class="oauth-provider-last-sign-in-badge"
|
||||
>
|
||||
{{ formatMessage(messages.lastSignInLabel) }}
|
||||
</span>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<a
|
||||
class="oauth-provider-link !shadow-none"
|
||||
:class="{ 'oauth-provider-link--last': lastSignInProvider === 'github' }"
|
||||
:href="getAuthUrl('github', redirectTarget)"
|
||||
@click="onOAuthProviderClick('github')"
|
||||
>
|
||||
<GitHubColorIcon />
|
||||
<span class="ml-1">{{
|
||||
formatMessage(messages.continueWithProvider, { provider: 'GitHub' })
|
||||
}}</span>
|
||||
<span
|
||||
v-if="lastSignInProvider === 'github'"
|
||||
class="oauth-provider-last-sign-in-badge"
|
||||
>
|
||||
{{ formatMessage(messages.lastSignInLabel) }}
|
||||
</span>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<a
|
||||
class="oauth-provider-link !shadow-none"
|
||||
:class="{ 'oauth-provider-link--last': lastSignInProvider === 'gitlab' }"
|
||||
:href="getAuthUrl('gitlab', redirectTarget)"
|
||||
@click="onOAuthProviderClick('gitlab')"
|
||||
>
|
||||
<GitLabColorIcon />
|
||||
<span class="ml-1">{{
|
||||
formatMessage(messages.continueWithProvider, { provider: 'GitLab' })
|
||||
}}</span>
|
||||
<span
|
||||
v-if="lastSignInProvider === 'gitlab'"
|
||||
class="oauth-provider-last-sign-in-badge"
|
||||
>
|
||||
{{ formatMessage(messages.lastSignInLabel) }}
|
||||
</span>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<a
|
||||
class="oauth-provider-link !shadow-none"
|
||||
:class="{ 'oauth-provider-link--last': lastSignInProvider === 'steam' }"
|
||||
:href="getAuthUrl('steam', redirectTarget)"
|
||||
@click="onOAuthProviderClick('steam')"
|
||||
>
|
||||
<SteamColorIcon />
|
||||
<span class="ml-1">{{
|
||||
formatMessage(messages.continueWithProvider, { provider: 'Steam' })
|
||||
}}</span>
|
||||
<span v-if="lastSignInProvider === 'steam'" class="oauth-provider-last-sign-in-badge">
|
||||
{{ formatMessage(messages.lastSignInLabel) }}
|
||||
</span>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</section>
|
||||
|
||||
<div class="h-px w-full bg-surface-5"></div>
|
||||
|
||||
<section class="flex flex-col gap-2.5">
|
||||
<label for="email" hidden>{{ formatMessage(commonMessages.emailUsernameLabel) }}</label>
|
||||
<StyledInput
|
||||
id="email"
|
||||
v-model="emailModel"
|
||||
:icon="MailIcon"
|
||||
type="text"
|
||||
inputmode="email"
|
||||
autocomplete="username"
|
||||
:placeholder="formatMessage(commonMessages.emailUsernameLabel)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
|
||||
<label for="password" hidden>{{ formatMessage(commonMessages.passwordLabel) }}</label>
|
||||
<StyledInput
|
||||
id="password"
|
||||
v-model="passwordModel"
|
||||
:icon="KeyIcon"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
:placeholder="formatMessage(commonMessages.passwordLabel)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
|
||||
<HCaptcha
|
||||
v-if="globals?.captcha_enabled && emailModel && passwordModel"
|
||||
:ref="onSetCaptchaRef"
|
||||
v-model="tokenModel"
|
||||
/>
|
||||
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
class="!w-full"
|
||||
:disabled="globals?.captcha_enabled ? !tokenModel : false"
|
||||
@click="onPasswordSignIn()"
|
||||
>
|
||||
{{ formatMessage(messages.continueWithEmail) }} <RightArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-2.5 !text-base">
|
||||
<NuxtLink
|
||||
class="text-link"
|
||||
:to="{
|
||||
path: '/auth/reset-password',
|
||||
query: routeQuery,
|
||||
}"
|
||||
>
|
||||
{{ formatMessage(messages.forgotPasswordLabel) }}
|
||||
</NuxtLink>
|
||||
<div class="h-1.5 w-1.5 rounded-full bg-surface-5" />
|
||||
<NuxtLink
|
||||
class="inline text-link"
|
||||
:to="{
|
||||
path: '/auth/sign-up',
|
||||
query: routeQuery,
|
||||
}"
|
||||
>
|
||||
{{ formatMessage(messages.createAccountLabel) }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DiscordColorIcon,
|
||||
GitHubColorIcon,
|
||||
GitLabColorIcon,
|
||||
GoogleColorIcon,
|
||||
KeyIcon,
|
||||
MailIcon,
|
||||
MicrosoftColorIcon,
|
||||
RightArrowIcon,
|
||||
SteamColorIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { ButtonStyled, commonMessages, defineMessages, StyledInput, useVIntl } from '@modrinth/ui'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
|
||||
import HCaptcha from '@/components/ui/auth/HCaptcha.vue'
|
||||
import {
|
||||
getAuthUrl,
|
||||
LAST_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
|
||||
PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
|
||||
} from '@/composables/auth.ts'
|
||||
|
||||
type AuthProvider = 'discord' | 'google' | 'github' | 'gitlab' | 'steam' | 'microsoft'
|
||||
|
||||
interface AuthGlobals {
|
||||
captcha_enabled?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface Props {
|
||||
subtleLauncherRedirectUri?: string
|
||||
flow?: string
|
||||
redirectTarget?: string
|
||||
routeQuery?: LocationQuery
|
||||
globals?: AuthGlobals | null
|
||||
onPasswordSignIn?: () => void
|
||||
onTwoFactorSignIn?: () => void
|
||||
onSetCaptchaRef?: ((captchaRef: unknown) => void) | undefined
|
||||
}
|
||||
|
||||
const {
|
||||
subtleLauncherRedirectUri = '',
|
||||
flow = '',
|
||||
redirectTarget = '',
|
||||
routeQuery = {},
|
||||
globals = null,
|
||||
onPasswordSignIn = () => {},
|
||||
onTwoFactorSignIn = () => {},
|
||||
onSetCaptchaRef = undefined,
|
||||
} = defineProps<Props>()
|
||||
|
||||
const emailModel = defineModel<string>('email', { default: '' })
|
||||
const passwordModel = defineModel<string>('password', { default: '' })
|
||||
const tokenModel = defineModel<string>('token', { default: '' })
|
||||
const twoFactorCodeModel = defineModel<string>('twoFactorCode', { default: '' })
|
||||
|
||||
const lastSignInOAuthProvider = useStorage<AuthProvider | null>(
|
||||
LAST_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
|
||||
null,
|
||||
undefined,
|
||||
{ initOnMounted: true },
|
||||
)
|
||||
const pendingSignInOAuthProvider = useStorage<AuthProvider | null>(
|
||||
PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
|
||||
null,
|
||||
undefined,
|
||||
{ initOnMounted: true },
|
||||
)
|
||||
const lastSignInProvider = computed(() => lastSignInOAuthProvider.value)
|
||||
const onOAuthProviderClick = (provider: AuthProvider) => {
|
||||
pendingSignInOAuthProvider.value = provider
|
||||
}
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
forgotPasswordLabel: {
|
||||
id: 'auth.sign-in.forgot-password',
|
||||
defaultMessage: 'Forgot password',
|
||||
},
|
||||
noAccountLabel: {
|
||||
id: 'auth.sign-in.no-account',
|
||||
defaultMessage: "Don't have an account?",
|
||||
},
|
||||
createAccountLabel: {
|
||||
id: 'auth.sign-in.create-account',
|
||||
defaultMessage: 'Sign up',
|
||||
},
|
||||
signInWithLabel: {
|
||||
id: 'auth.sign-in.sign-in-with',
|
||||
defaultMessage: 'Sign into Modrinth',
|
||||
},
|
||||
twoFactorCodeInputPlaceholder: {
|
||||
id: 'auth.sign-in.2fa.placeholder',
|
||||
defaultMessage: 'Enter code...',
|
||||
},
|
||||
twoFactorCodeLabel: {
|
||||
id: 'auth.sign-in.2fa.label',
|
||||
defaultMessage: 'Two-factor authentication',
|
||||
},
|
||||
twoFactorCodeLabelDescription: {
|
||||
id: 'auth.sign-in.2fa.description',
|
||||
defaultMessage:
|
||||
'Enter the 6-digit code from your authenticator app, or one of your backup codes.',
|
||||
},
|
||||
continueWithProvider: {
|
||||
id: 'auth.continue-with-provider',
|
||||
defaultMessage: 'Continue with {provider}',
|
||||
},
|
||||
continueWithEmail: {
|
||||
id: 'auth.sign-in.continue-with-email',
|
||||
defaultMessage: 'Continue with Email',
|
||||
},
|
||||
lastSignInLabel: {
|
||||
id: 'auth.sign-in.last-sign-in',
|
||||
defaultMessage: 'Last sign in',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.oauth-provider-link {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.oauth-provider-link--last {
|
||||
border-width: 1px !important;
|
||||
border-color: var(--color-green) !important;
|
||||
}
|
||||
|
||||
.oauth-provider-last-sign-in-badge {
|
||||
position: absolute;
|
||||
top: -0.75rem;
|
||||
right: 0.5rem;
|
||||
z-index: 1;
|
||||
border-radius: 9999px;
|
||||
background-color: var(--surface-3);
|
||||
color: var(--color-green);
|
||||
border: 1px solid var(--color-green);
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.oauth-provider-last-sign-in-badge::before {
|
||||
content: '';
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background-color: var(--color-green-highlight);
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<div
|
||||
class="universal-card flex w-full max-w-[28rem] flex-col gap-6 border border-solid border-surface-5"
|
||||
>
|
||||
<div class="mx-auto text-center text-2xl font-semibold text-contrast">
|
||||
{{ formatMessage(messages.signUpWithTitle) }}
|
||||
</div>
|
||||
<section class="flex flex-col gap-2.5">
|
||||
<ButtonStyled>
|
||||
<a
|
||||
class="!shadow-none"
|
||||
:href="getAuthUrl('google', redirectTarget)"
|
||||
@click="onOAuthProviderClick('google')"
|
||||
>
|
||||
<GoogleColorIcon />
|
||||
<span>{{ formatMessage(messages.continueWithProvider, { provider: 'Google' }) }}</span>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<a
|
||||
class="!shadow-none"
|
||||
:href="getAuthUrl('microsoft', redirectTarget)"
|
||||
@click="onOAuthProviderClick('microsoft')"
|
||||
>
|
||||
<MicrosoftColorIcon />
|
||||
<span>{{ formatMessage(messages.continueWithProvider, { provider: 'Microsoft' }) }}</span>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<a
|
||||
class="!shadow-none"
|
||||
:href="getAuthUrl('discord', redirectTarget)"
|
||||
@click="onOAuthProviderClick('discord')"
|
||||
>
|
||||
<DiscordColorIcon />
|
||||
<span>{{ formatMessage(messages.continueWithProvider, { provider: 'Discord' }) }}</span>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<template v-if="showOtherOptions">
|
||||
<ButtonStyled>
|
||||
<a
|
||||
class="!shadow-none"
|
||||
:href="getAuthUrl('github', redirectTarget)"
|
||||
@click="onOAuthProviderClick('github')"
|
||||
>
|
||||
<GitHubColorIcon />
|
||||
<span>{{ formatMessage(messages.continueWithProvider, { provider: 'GitHub' }) }}</span>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<a
|
||||
class="!shadow-none"
|
||||
:href="getAuthUrl('gitlab', redirectTarget)"
|
||||
@click="onOAuthProviderClick('gitlab')"
|
||||
>
|
||||
<GitLabColorIcon />
|
||||
<span>{{ formatMessage(messages.continueWithProvider, { provider: 'GitLab' }) }}</span>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<a
|
||||
class="!shadow-none"
|
||||
:href="getAuthUrl('steam', redirectTarget)"
|
||||
@click="onOAuthProviderClick('steam')"
|
||||
>
|
||||
<SteamColorIcon />
|
||||
<span>{{ formatMessage(messages.continueWithProvider, { provider: 'Steam' }) }}</span>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<button
|
||||
class="mx-auto -mb-3 bg-transparent pt-1 text-center text-base font-semibold text-secondary transition-all hover:text-primary"
|
||||
@click="onToggleOtherOptions()"
|
||||
>
|
||||
{{
|
||||
showOtherOptions
|
||||
? formatMessage(messages.showFewerOptions)
|
||||
: formatMessage(messages.showOtherOptions)
|
||||
}}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div class="h-px w-full bg-surface-5"></div>
|
||||
|
||||
<section class="flex flex-col gap-2.5">
|
||||
<label for="email" hidden>{{ formatMessage(commonMessages.emailLabel) }}</label>
|
||||
<StyledInput
|
||||
id="email"
|
||||
v-model="emailModel"
|
||||
:icon="MailIcon"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
:placeholder="formatMessage(commonMessages.emailLabel)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
|
||||
<label for="password" hidden>{{ formatMessage(commonMessages.passwordLabel) }}</label>
|
||||
<StyledInput
|
||||
id="password"
|
||||
v-model="passwordModel"
|
||||
:icon="KeyIcon"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:placeholder="formatMessage(commonMessages.passwordLabel)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
class="!w-full"
|
||||
:disabled="!emailModel || !passwordModel"
|
||||
@click="onContinueWithEmail()"
|
||||
>
|
||||
{{ formatMessage(messages.continueWithEmail) }} <RightArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<p v-if="!routeQuery.launcher" class="m-0 text-center">
|
||||
<IntlFormatted :message-id="messages.legalDisclaimer">
|
||||
<template #terms-link="{ children }">
|
||||
<NuxtLink to="/legal/terms" class="text-link">
|
||||
<component :is="() => children" />
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<template #privacy-policy-link="{ children }">
|
||||
<NuxtLink to="/legal/privacy" class="text-link">
|
||||
<component :is="() => children" />
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
|
||||
<div class="mx-auto flex flex-wrap items-center justify-start gap-2 text-center">
|
||||
{{ formatMessage(messages.alreadyHaveAccountLabel) }}
|
||||
<NuxtLink
|
||||
class="mr-1 text-link"
|
||||
:to="{
|
||||
path: '/auth/sign-in',
|
||||
query: routeQuery,
|
||||
}"
|
||||
>
|
||||
{{ formatMessage(commonMessages.signInButton) }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DiscordColorIcon,
|
||||
GitHubColorIcon,
|
||||
GitLabColorIcon,
|
||||
GoogleColorIcon,
|
||||
KeyIcon,
|
||||
MailIcon,
|
||||
MicrosoftColorIcon,
|
||||
RightArrowIcon,
|
||||
SteamColorIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
IntlFormatted,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
|
||||
import { getAuthUrl, PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY } from '@/composables/auth.ts'
|
||||
|
||||
type AuthProvider = 'discord' | 'google' | 'github' | 'gitlab' | 'steam' | 'microsoft'
|
||||
|
||||
interface Props {
|
||||
redirectTarget?: string
|
||||
showOtherOptions?: boolean
|
||||
routeQuery?: LocationQuery
|
||||
onToggleOtherOptions?: () => void
|
||||
onContinueWithEmail?: () => void
|
||||
}
|
||||
|
||||
const {
|
||||
redirectTarget = '',
|
||||
showOtherOptions = false,
|
||||
routeQuery = {},
|
||||
onToggleOtherOptions = () => {},
|
||||
onContinueWithEmail = () => {},
|
||||
} = defineProps<Props>()
|
||||
|
||||
const emailModel = defineModel<string>('email', { default: '' })
|
||||
const passwordModel = defineModel<string>('password', { default: '' })
|
||||
|
||||
const pendingSignInOAuthProvider = useStorage<AuthProvider | null>(
|
||||
PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
|
||||
null,
|
||||
)
|
||||
const onOAuthProviderClick = (provider: AuthProvider) => {
|
||||
pendingSignInOAuthProvider.value = provider
|
||||
}
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
signUpWithTitle: {
|
||||
id: 'auth.sign-up.title.sign-up-with',
|
||||
defaultMessage: 'Create an Account',
|
||||
},
|
||||
continueWithProvider: {
|
||||
id: 'auth.continue-with-provider',
|
||||
defaultMessage: 'Continue with {provider}',
|
||||
},
|
||||
legalDisclaimer: {
|
||||
id: 'auth.sign-up.legal-dislaimer',
|
||||
defaultMessage:
|
||||
"By creating an account, you agree to Modrinth's <terms-link>Terms</terms-link> and <privacy-policy-link>Privacy Policy</privacy-policy-link>.",
|
||||
},
|
||||
alreadyHaveAccountLabel: {
|
||||
id: 'auth.sign-up.sign-in-option.title',
|
||||
defaultMessage: 'Already have an account?',
|
||||
},
|
||||
continueWithEmail: {
|
||||
id: 'auth.sign-up.continue-with-email',
|
||||
defaultMessage: 'Continue with Email',
|
||||
},
|
||||
showFewerOptions: {
|
||||
id: 'auth.sign-up.show-fewer-options',
|
||||
defaultMessage: 'Show fewer options',
|
||||
},
|
||||
showOtherOptions: {
|
||||
id: 'auth.sign-up.show-other-options',
|
||||
defaultMessage: 'Show other options',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -166,9 +166,7 @@ defineExpose({
|
||||
show,
|
||||
})
|
||||
|
||||
const auth = (await useAuth()) as Ref<{
|
||||
user: { id: string; username: string; avatar_url: string } | null
|
||||
}>
|
||||
const auth = await useAuth()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
|
||||
+1
-2
@@ -120,7 +120,7 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import RevenueInputField from '@/components/ui/dashboard/RevenueInputField.vue'
|
||||
import WithdrawFeeBreakdown from '@/components/ui/dashboard/WithdrawFeeBreakdown.vue'
|
||||
import { getAuthUrl, removeAuthProvider, useAuth } from '@/composables/auth.js'
|
||||
import { getAuthUrl, removeAuthProvider, useAuth } from '@/composables/auth.ts'
|
||||
import { useWithdrawContext } from '@/providers/creator-withdraw.ts'
|
||||
|
||||
const { withdrawData, maxWithdrawAmount, availableMethods, calculateFees, saveStateToStorage } =
|
||||
@@ -193,7 +193,6 @@ async function saveVenmoHandle() {
|
||||
},
|
||||
})
|
||||
|
||||
// @ts-expect-error auth.js is not typed
|
||||
await useAuth(auth.value.token)
|
||||
|
||||
initialVenmoHandle.value = venmoHandle.value.trim()
|
||||
|
||||
+1
-1
@@ -358,8 +358,8 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import RevenueInputField from '@/components/ui/dashboard/RevenueInputField.vue'
|
||||
import WithdrawFeeBreakdown from '@/components/ui/dashboard/WithdrawFeeBreakdown.vue'
|
||||
import { useAuth } from '@/composables/auth.js'
|
||||
import { useWithdrawContext } from '@/providers/creator-withdraw.ts'
|
||||
import { useAuth } from '~/composables/auth.ts'
|
||||
|
||||
const debug = useDebugLogger('TremendousDetailsStage')
|
||||
const { withdrawData, maxWithdrawAmount, availableMethods, paymentOptions, calculateFees } =
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<ButtonStyled circular>
|
||||
<button :class="!collapsed && '[&>svg]:rotate-180'" @click="$emit('toggleCollapsed')">
|
||||
<button :class="{ '[&>svg]:rotate-180': !collapsed }" @click="$emit('toggleCollapsed')">
|
||||
<DropdownIcon class="duration-250 transition-transform ease-in-out" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
@@ -190,7 +190,6 @@ import {
|
||||
LinkIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { type ExtendedReport, reportQuickReplies } from '@modrinth/moderation'
|
||||
import { type OverflowMenuOption, useFormatDateTime } from '@modrinth/ui'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
@@ -198,6 +197,8 @@ import {
|
||||
getProjectTypeIcon,
|
||||
injectNotificationManager,
|
||||
OverflowMenu,
|
||||
type OverflowMenuOption,
|
||||
useFormatDateTime,
|
||||
useRelativeTime,
|
||||
} from '@modrinth/ui'
|
||||
import { formatProjectType } from '@modrinth/utils'
|
||||
|
||||
@@ -26,12 +26,12 @@ import {
|
||||
getProjectTypeIcon,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
NavTabs,
|
||||
OverflowMenu,
|
||||
type OverflowMenuOption,
|
||||
useFormatBytes,
|
||||
useFormatDateTime,
|
||||
} from '@modrinth/ui'
|
||||
import { NavTabs } from '@modrinth/ui'
|
||||
import {
|
||||
capitalizeString,
|
||||
formatProjectType,
|
||||
|
||||
+1
-1
@@ -87,7 +87,7 @@ const currentProjectId = computed(() => projectV3.value?.id)
|
||||
const { selectedProjectId, selectedVersionId } = injectServerCompatibilityContext()
|
||||
const { labrinth } = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const auth = (await useAuth()) as { user?: { id: string } }
|
||||
const auth = await useAuth()
|
||||
|
||||
interface VersionInfo {
|
||||
id: string
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="visibleQuickReplies.length > 0">
|
||||
<OverflowMenu :options="visibleQuickReplies">
|
||||
Quick Reply
|
||||
Quick reply
|
||||
<ChevronDownIcon />
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
function normalizeAuthToken(value) {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export const useAuth = async (oldToken = null) => {
|
||||
const auth = useState('auth', () => ({
|
||||
user: null,
|
||||
token: '',
|
||||
headers: {},
|
||||
}))
|
||||
|
||||
if (!auth.value.user || oldToken) {
|
||||
auth.value = await initAuth(oldToken)
|
||||
}
|
||||
|
||||
return auth
|
||||
}
|
||||
|
||||
export const initAuth = async (oldToken = null) => {
|
||||
const auth = {
|
||||
user: null,
|
||||
token: '',
|
||||
}
|
||||
|
||||
if (oldToken === 'none') {
|
||||
return auth
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const config = useRuntimeConfig()
|
||||
const authCookie = useCookie('auth-token', {
|
||||
maxAge: 60 * 60 * 24 * 365 * 10,
|
||||
sameSite: 'lax',
|
||||
secure: config.public.cookieSecure,
|
||||
httpOnly: false,
|
||||
path: '/',
|
||||
})
|
||||
|
||||
if (oldToken) {
|
||||
const normalized = normalizeAuthToken(oldToken)
|
||||
if (normalized) {
|
||||
authCookie.value = normalized
|
||||
}
|
||||
}
|
||||
|
||||
const oauthCode = normalizeAuthToken(route.query.code)
|
||||
if (oauthCode && !route.fullPath.includes('new_account=true')) {
|
||||
authCookie.value = oauthCode
|
||||
}
|
||||
|
||||
if (route.fullPath.includes('new_account=true') && route.path !== '/auth/welcome') {
|
||||
const redirect = route.path.startsWith('/auth/') ? null : route.fullPath
|
||||
|
||||
await navigateTo(
|
||||
`/auth/welcome?authToken=${oauthCode}${
|
||||
redirect ? `&redirect=${encodeURIComponent(redirect)}` : ''
|
||||
}`,
|
||||
)
|
||||
}
|
||||
|
||||
const tokenStr = normalizeAuthToken(authCookie.value)
|
||||
|
||||
if (authCookie.value != null && tokenStr === '') {
|
||||
authCookie.value = null
|
||||
} else if (tokenStr) {
|
||||
auth.token = tokenStr
|
||||
|
||||
if (!auth.token.startsWith('mra_')) {
|
||||
return auth
|
||||
}
|
||||
|
||||
try {
|
||||
auth.user = await useBaseFetch(
|
||||
'user',
|
||||
{
|
||||
apiVersion: 3,
|
||||
headers: {
|
||||
Authorization: auth.token,
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
if (!auth.user && auth.token && typeof auth.token === 'string') {
|
||||
try {
|
||||
const session = await useBaseFetch(
|
||||
'session/refresh',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: auth.token,
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
|
||||
auth.token = normalizeAuthToken(session.session)
|
||||
if (auth.token) {
|
||||
authCookie.value = auth.token
|
||||
auth.user = await useBaseFetch(
|
||||
'user',
|
||||
{
|
||||
apiVersion: 3,
|
||||
headers: {
|
||||
Authorization: auth.token,
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
} else {
|
||||
authCookie.value = null
|
||||
auth.token = ''
|
||||
}
|
||||
} catch {
|
||||
authCookie.value = null
|
||||
}
|
||||
}
|
||||
|
||||
return auth
|
||||
}
|
||||
|
||||
export const getSignInRedirectPath = (route) => {
|
||||
const fullPath = route.fullPath
|
||||
if (fullPath === '/auth' || fullPath.startsWith('/auth/')) {
|
||||
return '/dashboard'
|
||||
}
|
||||
return fullPath
|
||||
}
|
||||
|
||||
export const getSignInRouteObj = (route, redirectOverride) => ({
|
||||
path: '/auth/sign-in',
|
||||
query: {
|
||||
redirect: redirectOverride ?? getSignInRedirectPath(route),
|
||||
},
|
||||
})
|
||||
|
||||
export const getAuthUrl = (provider, redirect = '/dashboard') => {
|
||||
const config = useRuntimeConfig()
|
||||
const route = useNativeRoute()
|
||||
|
||||
const fullURL = route.query.launcher
|
||||
? getLauncherRedirectUrl(route)
|
||||
: `${config.public.siteUrl}/auth/sign-in?redirect=${encodeURIComponent(redirect)}`
|
||||
|
||||
return `${config.public.apiBaseUrl}auth/init?provider=${provider}&url=${encodeURIComponent(fullURL)}`
|
||||
}
|
||||
|
||||
export const removeAuthProvider = async (provider) => {
|
||||
startLoading()
|
||||
|
||||
const auth = await useAuth()
|
||||
|
||||
await useBaseFetch('auth/provider', {
|
||||
method: 'DELETE',
|
||||
body: {
|
||||
provider,
|
||||
},
|
||||
})
|
||||
|
||||
await useAuth(auth.value.token)
|
||||
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
export const getLauncherRedirectUrl = (route) => {
|
||||
const usesLocalhostRedirectionScheme =
|
||||
['4', '6'].includes(route.query.ipver) && Number(route.query.port) < 65536
|
||||
|
||||
return usesLocalhostRedirectionScheme
|
||||
? `http://${route.query.ipver === '4' ? '127.0.0.1' : '[::1]'}:${route.query.port}`
|
||||
: `https://launcher-files.modrinth.com`
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import type { LocationQueryValue, RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
|
||||
import type { CookieOptions } from '#app'
|
||||
|
||||
type AuthState = {
|
||||
user: Labrinth.Users.v2.User | null
|
||||
token: string
|
||||
}
|
||||
|
||||
type QueryValue = LocationQueryValue | LocationQueryValue[] | undefined
|
||||
type FullPathRoute = Pick<RouteLocationNormalizedLoaded, 'fullPath'>
|
||||
type LauncherRoute = Pick<RouteLocationNormalizedLoaded, 'query'>
|
||||
|
||||
export const LAST_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY = 'auth-last-sign-in-oauth-provider'
|
||||
export const PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY = 'auth-pending-sign-in-oauth-provider'
|
||||
|
||||
const AUTH_COOKIE_OPTIONS = {
|
||||
maxAge: 60 * 60 * 24 * 365 * 10,
|
||||
sameSite: 'lax',
|
||||
httpOnly: false,
|
||||
path: '/',
|
||||
} satisfies CookieOptions<string | null>
|
||||
|
||||
const normalizeAuthToken = (value: unknown) => {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const getQueryString = (value: QueryValue) => {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0] ?? null
|
||||
}
|
||||
return value ?? null
|
||||
}
|
||||
|
||||
export const useAuth = async (oldToken: string | null | undefined = null) => {
|
||||
const auth = useState<AuthState>('auth', () => ({
|
||||
user: null,
|
||||
token: '',
|
||||
}))
|
||||
|
||||
if (!auth.value.user || oldToken) {
|
||||
auth.value = await initAuth(oldToken)
|
||||
}
|
||||
|
||||
return auth
|
||||
}
|
||||
|
||||
export const initAuth = async (oldToken: string | null | undefined = null) => {
|
||||
const auth: AuthState = {
|
||||
user: null,
|
||||
token: '',
|
||||
}
|
||||
|
||||
if (oldToken === 'none') {
|
||||
return auth
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const config = useRuntimeConfig()
|
||||
const authCookie = useCookie<string | null>('auth-token', {
|
||||
...AUTH_COOKIE_OPTIONS,
|
||||
secure: config.public.cookieSecure,
|
||||
})
|
||||
|
||||
if (oldToken) {
|
||||
const normalized = normalizeAuthToken(oldToken)
|
||||
if (normalized) {
|
||||
authCookie.value = normalized
|
||||
}
|
||||
}
|
||||
|
||||
const oauthCode = normalizeAuthToken(route.query.code)
|
||||
if (oauthCode && !route.fullPath.includes('new_account=true')) {
|
||||
authCookie.value = oauthCode
|
||||
}
|
||||
|
||||
if (route.fullPath.includes('new_account=true') && route.path !== '/auth/welcome') {
|
||||
const redirect = route.path.startsWith('/auth/') ? null : route.fullPath
|
||||
|
||||
await navigateTo(
|
||||
`/auth/welcome?authToken=${oauthCode}${
|
||||
redirect ? `&redirect=${encodeURIComponent(redirect)}` : ''
|
||||
}`,
|
||||
)
|
||||
}
|
||||
|
||||
const tokenStr = normalizeAuthToken(authCookie.value)
|
||||
|
||||
if (authCookie.value != null && tokenStr === '') {
|
||||
authCookie.value = null
|
||||
} else if (tokenStr) {
|
||||
auth.token = tokenStr
|
||||
|
||||
if (!auth.token.startsWith('mra_')) {
|
||||
return auth
|
||||
}
|
||||
|
||||
try {
|
||||
auth.user = (await useBaseFetch(
|
||||
'user',
|
||||
{
|
||||
apiVersion: 3,
|
||||
headers: {
|
||||
Authorization: auth.token,
|
||||
},
|
||||
},
|
||||
true,
|
||||
)) as Labrinth.Users.v2.User
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
if (!auth.user && auth.token) {
|
||||
try {
|
||||
const session = (await useBaseFetch(
|
||||
'session/refresh',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: auth.token,
|
||||
},
|
||||
},
|
||||
true,
|
||||
)) as { session: unknown }
|
||||
|
||||
auth.token = normalizeAuthToken(session.session)
|
||||
if (auth.token) {
|
||||
authCookie.value = auth.token
|
||||
auth.user = (await useBaseFetch(
|
||||
'user',
|
||||
{
|
||||
apiVersion: 3,
|
||||
headers: {
|
||||
Authorization: auth.token,
|
||||
},
|
||||
},
|
||||
true,
|
||||
)) as Labrinth.Users.v2.User
|
||||
} else {
|
||||
authCookie.value = null
|
||||
auth.token = ''
|
||||
}
|
||||
} catch {
|
||||
authCookie.value = null
|
||||
}
|
||||
}
|
||||
|
||||
return auth
|
||||
}
|
||||
|
||||
export const getSignInRedirectPath = (route: FullPathRoute) => {
|
||||
const fullPath = route.fullPath
|
||||
if (fullPath === '/auth' || fullPath.startsWith('/auth/')) {
|
||||
return '/dashboard'
|
||||
}
|
||||
return fullPath
|
||||
}
|
||||
|
||||
export const getSignInRouteObj = (route: FullPathRoute, redirectOverride?: string | null) => ({
|
||||
path: '/auth/sign-in',
|
||||
query: {
|
||||
redirect: redirectOverride ?? getSignInRedirectPath(route),
|
||||
},
|
||||
})
|
||||
|
||||
export const getAuthUrl = (provider: string, redirect = '/dashboard') => {
|
||||
const config = useRuntimeConfig()
|
||||
const route = useNativeRoute()
|
||||
const launcher = getQueryString(route.query.launcher)
|
||||
|
||||
const fullURL = launcher
|
||||
? (() => {
|
||||
const callbackUrl = new URL('/auth/sign-in', config.public.siteUrl)
|
||||
callbackUrl.searchParams.set('launcher', launcher)
|
||||
|
||||
const ipver = getQueryString(route.query.ipver)
|
||||
const port = getQueryString(route.query.port)
|
||||
|
||||
if (ipver) {
|
||||
callbackUrl.searchParams.set('ipver', ipver)
|
||||
}
|
||||
|
||||
if (port) {
|
||||
callbackUrl.searchParams.set('port', port)
|
||||
}
|
||||
|
||||
return callbackUrl.toString()
|
||||
})()
|
||||
: `${config.public.siteUrl}/auth/sign-in?redirect=${encodeURIComponent(redirect)}`
|
||||
|
||||
return `${config.public.apiBaseUrl}auth/init?provider=${provider}&url=${encodeURIComponent(fullURL)}`
|
||||
}
|
||||
|
||||
export const promotePendingSignInOAuthProvider = () => {
|
||||
if (!import.meta.client) return
|
||||
const pending = useStorage<string | null>(
|
||||
PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
|
||||
null,
|
||||
undefined,
|
||||
{ initOnMounted: true },
|
||||
)
|
||||
if (!pending.value) return
|
||||
const last = useStorage<string | null>(LAST_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY, null, undefined, {
|
||||
initOnMounted: true,
|
||||
})
|
||||
last.value = pending.value
|
||||
pending.value = null
|
||||
}
|
||||
|
||||
export const removeAuthProvider = async (provider: string) => {
|
||||
startLoading()
|
||||
|
||||
const auth = await useAuth()
|
||||
|
||||
await useBaseFetch('auth/provider', {
|
||||
method: 'DELETE',
|
||||
body: {
|
||||
provider,
|
||||
},
|
||||
})
|
||||
|
||||
await useAuth(auth.value.token)
|
||||
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
export const getLauncherRedirectUrl = (route: LauncherRoute) => {
|
||||
const ipver = getQueryString(route.query.ipver)
|
||||
const port = Number(getQueryString(route.query.port))
|
||||
const usesLocalhostRedirectionScheme = ['4', '6'].includes(ipver ?? '') && port < 65536
|
||||
|
||||
return usesLocalhostRedirectionScheme
|
||||
? `http://${ipver === '4' ? '127.0.0.1' : '[::1]'}:${port}`
|
||||
: 'https://launcher-files.modrinth.com'
|
||||
}
|
||||
@@ -48,7 +48,6 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
useV1ContentTabAPI: true,
|
||||
labrinthApiCanary: false,
|
||||
dismissedExternalProjectsInfo: false,
|
||||
modpackPermissionsPage: false,
|
||||
showAllBanners: false,
|
||||
alwaysIgnoreErrorBanner: false,
|
||||
showViewProdRouteBanner: false,
|
||||
@@ -56,6 +55,8 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
showModeratorPrivateMessageHighlight: true,
|
||||
archonApiStaging: false,
|
||||
showHostingAccessInstanceAuditLog: false,
|
||||
versionDevInfoCollapsed: true,
|
||||
alwaysShowVersionDevInfo: false,
|
||||
} as const)
|
||||
|
||||
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
|
||||
|
||||
@@ -8,4 +8,11 @@ export const versionQueryOptions = {
|
||||
queryFn: () => client.labrinth.versions_v3.getVersion(versionId),
|
||||
staleTime: STALE_TIME,
|
||||
}),
|
||||
|
||||
fromProject: (projectId: string, versionIdOrNumber: string, client: AbstractModrinthClient) => ({
|
||||
queryKey: ['project', projectId, 'version', 'v3', versionIdOrNumber] as const,
|
||||
queryFn: () =>
|
||||
client.labrinth.versions_v3.getVersionFromIdOrNumber(projectId, versionIdOrNumber),
|
||||
staleTime: STALE_TIME,
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -799,7 +799,7 @@ import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.
|
||||
import OrganizationCreateModal from '~/components/ui/create/OrganizationCreateModal.vue'
|
||||
import ProjectCreateModal from '~/components/ui/create/ProjectCreateModal.vue'
|
||||
import ModrinthFooter from '~/components/ui/ModrinthFooter.vue'
|
||||
import { getSignInRouteObj } from '~/composables/auth.js'
|
||||
import { getSignInRouteObj } from '~/composables/auth.ts'
|
||||
import { errors as generatedStateErrors } from '~/generated/state.json'
|
||||
import { getProjectTypeMessage } from '~/utils/i18n-project-type.ts'
|
||||
import { hasActiveMidas } from '~/utils/user-membership.ts'
|
||||
|
||||
@@ -3290,9 +3290,6 @@
|
||||
"project.moderation.thread.help-center-note.2": {
|
||||
"message": "Wenn du Hilfe benötigst oder weitere Fragen hast, besuche bitte das <help-center-link>Hilfezentrum von Modrinth</help-center-link> und klicke auf die blaue Sprechblase, um den Support zu kontaktieren."
|
||||
},
|
||||
"project.moderation.thread.moderator-see-user-ui-toggle": {
|
||||
"message": "Benutzer-UI anzeigen"
|
||||
},
|
||||
"project.moderation.thread.private-description": {
|
||||
"message": "Dies ist ein privater Unterhaltungsthread mit den Modrinth-Moderatoren. Sie können dich bei Problemen bezüglich dieses Projekts kontaktieren."
|
||||
},
|
||||
@@ -3422,9 +3419,6 @@
|
||||
"project.versions.withheld-versions-warning.description": {
|
||||
"message": "{count, plural, one {Diese Version ist} other {Diese Versionen sind}} derzeit zurückgehalten und nicht öffentlich gelistet. Bitte stelle einen Nachweis bereit, dass du die Erlaubnis hast, bestimmte Dateien weiterzuverbreiten, die in {count, plural, one {der Modpack-Version} other {den Modpack-Versionen}} enthalten sind."
|
||||
},
|
||||
"project.versions.withheld-versions-warning.resolve-button": {
|
||||
"message": "Beheben"
|
||||
},
|
||||
"project.versions.withheld-versions-warning.title": {
|
||||
"message": "{count, plural, one {Version {version_name}} other {Versionen}} zurückgehalten aufgrund von unbekannten eingebetteten Inhalten"
|
||||
},
|
||||
|
||||
@@ -3290,9 +3290,6 @@
|
||||
"project.moderation.thread.help-center-note.2": {
|
||||
"message": "Falls du Hilfe benötigst oder weitere Fragen hast, besuche bitte das <help-center-link>Hilfezentrum von Modrinth</help-center-link> und klicke auf die blaue Sprechblase, um den Support zu kontaktieren."
|
||||
},
|
||||
"project.moderation.thread.moderator-see-user-ui-toggle": {
|
||||
"message": "Mitglieder-UI anzeigen"
|
||||
},
|
||||
"project.moderation.thread.private-description": {
|
||||
"message": "Dies ist eine private Konversation mit den Moderatoren von Modrinth. Diese können dir Nachrichten zu Problemen bezüglich dieses Projekts senden."
|
||||
},
|
||||
@@ -3422,9 +3419,6 @@
|
||||
"project.versions.withheld-versions-warning.description": {
|
||||
"message": "{count, plural, one {Diese Version ist} other {Diese Versionen sind}} derzeit zurückgehalten und nicht öffentlich gelistet. Bitte stelle einen Nachweis bereit, dass du die Erlaubnis hast, bestimmte Dateien weiterzuverbreiten, die im Modpack in {count, plural, one {Version} other {Versionen}} enthalten sind."
|
||||
},
|
||||
"project.versions.withheld-versions-warning.resolve-button": {
|
||||
"message": "Beheben"
|
||||
},
|
||||
"project.versions.withheld-versions-warning.title": {
|
||||
"message": "{count, plural, one {Version {version_name} wurde} other {Versionen wurden}} aufgrund unbekannter eingebetteter Inhalte zurückgehalten"
|
||||
},
|
||||
|
||||
@@ -731,12 +731,75 @@
|
||||
"auth.authorize.authorize-app-name": {
|
||||
"message": "Authorize {appName}"
|
||||
},
|
||||
"auth.authorize.error.missing-parameters": {
|
||||
"message": "Missing required OAuth query parameters."
|
||||
},
|
||||
"auth.authorize.error.no-redirect-url": {
|
||||
"message": "No redirect location found in response"
|
||||
},
|
||||
"auth.authorize.errro-title": {
|
||||
"message": "An Error Occured"
|
||||
},
|
||||
"auth.authorize.redirect-url": {
|
||||
"message": "You will be redirected to <redirect-url>{url}</redirect-url>"
|
||||
},
|
||||
"auth.continue-with-provider": {
|
||||
"message": "Continue with {provider}"
|
||||
},
|
||||
"auth.create-account.age-requirement.warning-title": {
|
||||
"message": "Age requirement"
|
||||
},
|
||||
"auth.create-account.complete-sign-up": {
|
||||
"message": "Complete sign up"
|
||||
},
|
||||
"auth.create-account.date-of-birth.invalid.text": {
|
||||
"message": "Please enter a valid date of birth. Year cannot be 0000."
|
||||
},
|
||||
"auth.create-account.date-of-birth.invalid.title": {
|
||||
"message": "Invalid date of birth"
|
||||
},
|
||||
"auth.create-account.date-of-birth.label": {
|
||||
"message": "Date of birth"
|
||||
},
|
||||
"auth.create-account.date-of-birth.over13-helper": {
|
||||
"message": "You must be over 13 years old to use Modrinth."
|
||||
},
|
||||
"auth.create-account.date-of-birth.placeholder": {
|
||||
"message": "Select your date of birth"
|
||||
},
|
||||
"auth.create-account.date-of-birth.required.text": {
|
||||
"message": "Please enter your date of birth before continuing."
|
||||
},
|
||||
"auth.create-account.date-of-birth.required.title": {
|
||||
"message": "Date of birth required"
|
||||
},
|
||||
"auth.create-account.date-of-birth.under13-helper": {
|
||||
"message": "You cannot create an account at Modrinth unless you are over 13 years old."
|
||||
},
|
||||
"auth.create-account.info-panel.source-code-link": {
|
||||
"message": "Relevant source code"
|
||||
},
|
||||
"auth.create-account.info-panel.text": {
|
||||
"message": "We do not store your date of birth, only whether you are over 13 to use Modrinth."
|
||||
},
|
||||
"auth.create-account.page-title": {
|
||||
"message": "Create Account"
|
||||
},
|
||||
"auth.create-account.security-check.label": {
|
||||
"message": "Security check"
|
||||
},
|
||||
"auth.create-account.subscribe.label": {
|
||||
"message": "Keep me updated on the cool things Modrinth is working on via email"
|
||||
},
|
||||
"auth.create-account.title": {
|
||||
"message": "Create an Account"
|
||||
},
|
||||
"auth.create-account.username.optional-label": {
|
||||
"message": "Username"
|
||||
},
|
||||
"auth.create-account.username.placeholder": {
|
||||
"message": "Enter username"
|
||||
},
|
||||
"auth.reset-password.method-choice.action": {
|
||||
"message": "Send recovery email"
|
||||
},
|
||||
@@ -771,53 +834,65 @@
|
||||
"message": "Reset your password"
|
||||
},
|
||||
"auth.sign-in.2fa.description": {
|
||||
"message": "Please enter a two-factor code to proceed."
|
||||
"message": "Enter the 6-digit code from your authenticator app, or one of your backup codes."
|
||||
},
|
||||
"auth.sign-in.2fa.label": {
|
||||
"message": "Enter two-factor code"
|
||||
"message": "Two-factor authentication"
|
||||
},
|
||||
"auth.sign-in.2fa.placeholder": {
|
||||
"message": "Enter code..."
|
||||
},
|
||||
"auth.sign-in.additional-options": {
|
||||
"message": "<forgot-password-link>Forgot password?</forgot-password-link> • <create-account-link>Create an account</create-account-link>"
|
||||
"auth.sign-in.continue-with-email": {
|
||||
"message": "Continue with Email"
|
||||
},
|
||||
"auth.sign-in.create-account": {
|
||||
"message": "Sign up"
|
||||
},
|
||||
"auth.sign-in.forgot-password": {
|
||||
"message": "Forgot password"
|
||||
},
|
||||
"auth.sign-in.last-sign-in": {
|
||||
"message": "Last sign in"
|
||||
},
|
||||
"auth.sign-in.no-account": {
|
||||
"message": "Don't have an account?"
|
||||
},
|
||||
"auth.sign-in.sign-in-with": {
|
||||
"message": "Sign in with"
|
||||
"message": "Sign into Modrinth"
|
||||
},
|
||||
"auth.sign-in.title": {
|
||||
"message": "Sign In"
|
||||
},
|
||||
"auth.sign-in.use-password": {
|
||||
"message": "Or use a password"
|
||||
"auth.sign-up.age-requirement.warning-title": {
|
||||
"message": "Age requirement"
|
||||
},
|
||||
"auth.sign-up.action.create-account": {
|
||||
"message": "Create account"
|
||||
"auth.sign-up.continue-with-email": {
|
||||
"message": "Continue with Email"
|
||||
},
|
||||
"auth.sign-up.legal-dislaimer": {
|
||||
"message": "By creating an account, you agree to Modrinth's <terms-link>Terms</terms-link> and <privacy-policy-link>Privacy Policy</privacy-policy-link>."
|
||||
},
|
||||
"auth.sign-up.notification.password-mismatch.text": {
|
||||
"message": "Passwords do not match!"
|
||||
"auth.sign-up.show-fewer-options": {
|
||||
"message": "Show fewer options"
|
||||
},
|
||||
"auth.sign-up.show-other-options": {
|
||||
"message": "Show other options"
|
||||
},
|
||||
"auth.sign-up.sign-in-option.title": {
|
||||
"message": "Already have an account?"
|
||||
},
|
||||
"auth.sign-up.subscribe.label": {
|
||||
"message": "Subscribe to updates about Modrinth"
|
||||
},
|
||||
"auth.sign-up.title": {
|
||||
"message": "Sign Up"
|
||||
},
|
||||
"auth.sign-up.title.create-account": {
|
||||
"message": "Or create an account yourself"
|
||||
},
|
||||
"auth.sign-up.title.sign-up-with": {
|
||||
"message": "Sign up with"
|
||||
"message": "Create an Account"
|
||||
},
|
||||
"auth.verify-email.action.account-settings": {
|
||||
"message": "Account settings"
|
||||
},
|
||||
"auth.verify-email.action.discover-mods": {
|
||||
"message": "Discover mods"
|
||||
},
|
||||
"auth.verify-email.already-verified.description": {
|
||||
"message": "Your email is already verified!"
|
||||
},
|
||||
@@ -836,6 +911,15 @@
|
||||
"auth.verify-email.failed-verification.title": {
|
||||
"message": "Email verification failed"
|
||||
},
|
||||
"auth.verify-email.notification.email-sent.description": {
|
||||
"message": "An email with a link to verify your account has been sent to {email}."
|
||||
},
|
||||
"auth.verify-email.notification.email-sent.title": {
|
||||
"message": "Email sent"
|
||||
},
|
||||
"auth.verify-email.notification.error-occurred.title": {
|
||||
"message": "An error occurred"
|
||||
},
|
||||
"auth.verify-email.post-verification.description": {
|
||||
"message": "Your email address has been successfully verified!"
|
||||
},
|
||||
@@ -845,21 +929,6 @@
|
||||
"auth.verify-email.title": {
|
||||
"message": "Verify Email"
|
||||
},
|
||||
"auth.welcome.checkbox.subscribe": {
|
||||
"message": "Subscribe to updates about Modrinth"
|
||||
},
|
||||
"auth.welcome.description": {
|
||||
"message": "You’re now part of the awesome community of creators & explorers already building, downloading, and staying up-to-date with amazing mods."
|
||||
},
|
||||
"auth.welcome.label.tos": {
|
||||
"message": "By creating an account, you have agreed to Modrinth's <terms-link>Terms</terms-link> and <privacy-policy-link>Privacy Policy</privacy-policy-link>."
|
||||
},
|
||||
"auth.welcome.long-title": {
|
||||
"message": "Welcome to Modrinth!"
|
||||
},
|
||||
"auth.welcome.title": {
|
||||
"message": "Welcome"
|
||||
},
|
||||
"collection.button.edit-icon": {
|
||||
"message": "Edit icon"
|
||||
},
|
||||
@@ -3332,9 +3401,6 @@
|
||||
"project.moderation.thread.help-center-note.2": {
|
||||
"message": "If you need assistance or have additional inquiries, please visit the <help-center-link>Modrinth Help Center</help-center-link> and click the blue bubble to contact support."
|
||||
},
|
||||
"project.moderation.thread.moderator-see-user-ui-toggle": {
|
||||
"message": "Show member UI"
|
||||
},
|
||||
"project.moderation.thread.private-description": {
|
||||
"message": "This is a private conversation thread with the Modrinth moderators. They may message you with issues concerning this project."
|
||||
},
|
||||
@@ -3405,43 +3471,76 @@
|
||||
"message": "URL"
|
||||
},
|
||||
"project.settings.permissions.attention-needed.description.proj-approved": {
|
||||
"message": "Please provide proof that you have permission to redistribute all of the following files and any withheld versions will be automatically published."
|
||||
"message": "Please provide proof that you have permission to redistribute all of the following files. Once completed, withheld versions will be automatically published."
|
||||
},
|
||||
"project.settings.permissions.attention-needed.description.proj-draft": {
|
||||
"message": "Please provide proof that you have permission to redistribute all of the following files before you can submit your project for review."
|
||||
"message": "Please provide proof that you have permission to redistribute all of the following files before submitting your project for review."
|
||||
},
|
||||
"project.settings.permissions.attention-needed.title": {
|
||||
"message": "Unknown embedded content"
|
||||
"message": "Unknown external content"
|
||||
},
|
||||
"project.settings.permissions.bad-proof.description": {
|
||||
"message": "Modrinth's moderation team has rejected the permission information you provided for some external content. Please review the rejected items below and provide acceptable proof or remove the content."
|
||||
},
|
||||
"project.settings.permissions.bad-proof.title": {
|
||||
"message": "Some proofs were rejected"
|
||||
},
|
||||
"project.settings.permissions.collapse-all": {
|
||||
"message": "Collapse all"
|
||||
},
|
||||
"project.settings.permissions.completed.description": {
|
||||
"message": "All external content has attributions provided."
|
||||
"message": "All external content has permission information and attributions have been provided."
|
||||
},
|
||||
"project.settings.permissions.completed.title": {
|
||||
"message": "Attributions completed!"
|
||||
"message": "Permissions completed!"
|
||||
},
|
||||
"project.settings.permissions.empty-state.description": {
|
||||
"message": "None of your versions contain external content, so you don't need to worry about obtaining permissions."
|
||||
"message": "None of your project's versions contain external content, so you don't need to worry about obtaining permissions."
|
||||
},
|
||||
"project.settings.permissions.empty-state.heading": {
|
||||
"message": "You're all set!"
|
||||
},
|
||||
"project.settings.permissions.expand-all": {
|
||||
"message": "Expand all"
|
||||
},
|
||||
"project.settings.permissions.fail.description": {
|
||||
"message": "You don't have permission to redistribute some of the external content you've added. In order to publish on Modrinth, remove the infringing content."
|
||||
"message": "You may not have permission to redistribute some of the external content in your project. In order to publish on Modrinth, please remove this content or provide proof that you do have permission to use it."
|
||||
},
|
||||
"project.settings.permissions.fail.title": {
|
||||
"message": "Some content can't be included"
|
||||
},
|
||||
"project.settings.permissions.info-banner.description": {
|
||||
"message": "If you include content that isn’t hosted on Modrinth, you need to let us know where it’s from and verify that you have permission to distribute the files. Check out <link>our guide</link> to learn about how to do this properly!"
|
||||
"message": "If you include content that isn’t hosted on Modrinth, you need to let us know where it’s from and verify that you have permission to distribute the files. Check out <link>our guide</link> to learn more and get started!"
|
||||
},
|
||||
"project.settings.permissions.info-banner.title": {
|
||||
"message": "Learn how attributions work"
|
||||
"message": "Learn about distribution permissions"
|
||||
},
|
||||
"project.settings.permissions.learn-more": {
|
||||
"message": "Learn more"
|
||||
},
|
||||
"project.settings.permissions.no-results": {
|
||||
"message": "No external files match your search."
|
||||
},
|
||||
"project.settings.permissions.not-allowed.description": {
|
||||
"message": "Some of the external content included cannot be distributed on Modrinth because it violates our Content Rules and must be removed."
|
||||
},
|
||||
"project.settings.permissions.pending-approval-count": {
|
||||
"message": "{count, plural, =0 {No attributions need approval} one {# attribution needs approval} other {# attributions need approval}}"
|
||||
},
|
||||
"project.settings.permissions.search-placeholder": {
|
||||
"message": "Search {count} {count, plural, one {external project} other {external projects}}..."
|
||||
"message": "Search {count} {count, plural, one {project} other {projects}}..."
|
||||
},
|
||||
"project.settings.permissions.sort.most-files": {
|
||||
"message": "Most files"
|
||||
},
|
||||
"project.settings.permissions.sort.recently-edited": {
|
||||
"message": "Recently edited"
|
||||
},
|
||||
"project.settings.permissions.sort.rejected": {
|
||||
"message": "Rejected"
|
||||
},
|
||||
"project.settings.permissions.sort.status": {
|
||||
"message": "Status"
|
||||
},
|
||||
"project.settings.title": {
|
||||
"message": "Settings"
|
||||
@@ -3464,9 +3563,6 @@
|
||||
"project.versions.withheld-versions-warning.description": {
|
||||
"message": "{count, plural, one {This version is} other {These versions are}} currently withheld and not publicly listed. Please provide proof that you have permission to redistribute certain files included in the modpack {count, plural, one {version} other {versions}}."
|
||||
},
|
||||
"project.versions.withheld-versions-warning.resolve-button": {
|
||||
"message": "Resolve"
|
||||
},
|
||||
"project.versions.withheld-versions-warning.title": {
|
||||
"message": "{count, plural, one {Version {version_name}} other {Versions}} withheld due to unknown embedded content"
|
||||
},
|
||||
@@ -4616,6 +4712,48 @@
|
||||
"ui.newsletter-button.tooltip": {
|
||||
"message": "Subscribe to the Modrinth newsletter"
|
||||
},
|
||||
"version.all-versions": {
|
||||
"message": "All versions"
|
||||
},
|
||||
"version.confirm-delete.description": {
|
||||
"message": "This version will be permanently deleted. This action cannot be undone."
|
||||
},
|
||||
"version.confirm-delete.proceed": {
|
||||
"message": "Delete version"
|
||||
},
|
||||
"version.confirm-delete.title": {
|
||||
"message": "Are you sure you want to delete this version?"
|
||||
},
|
||||
"version.dependency.view-project": {
|
||||
"message": "View project"
|
||||
},
|
||||
"version.dependency.view-version": {
|
||||
"message": "View version"
|
||||
},
|
||||
"version.download.download-dependency": {
|
||||
"message": "Download dependency"
|
||||
},
|
||||
"version.download.no-primary-file": {
|
||||
"message": "Error: No primary file found"
|
||||
},
|
||||
"version.download.optional-resource-pack": {
|
||||
"message": "Optional resource pack"
|
||||
},
|
||||
"version.download.required-resource-pack": {
|
||||
"message": "Required resource pack"
|
||||
},
|
||||
"version.edit.button": {
|
||||
"message": "Edit"
|
||||
},
|
||||
"version.edit.details": {
|
||||
"message": "Edit details"
|
||||
},
|
||||
"version.edit.files": {
|
||||
"message": "Edit files"
|
||||
},
|
||||
"version.edit.metadata": {
|
||||
"message": "Edit metadata"
|
||||
},
|
||||
"version.environment.none.description": {
|
||||
"message": "The environment for this version has not been specified."
|
||||
},
|
||||
@@ -4627,5 +4765,56 @@
|
||||
},
|
||||
"version.environment.unknown.title": {
|
||||
"message": "Unknown environment"
|
||||
},
|
||||
"version.package-as-mod.button": {
|
||||
"message": "Package as mod"
|
||||
},
|
||||
"version.package-as-mod.description": {
|
||||
"message": "This will create a new version with support for the selected mod loaders. You will be redirected to the new version and can edit it to your liking."
|
||||
},
|
||||
"version.package-as-mod.header": {
|
||||
"message": "Packaging data pack as a mod"
|
||||
},
|
||||
"version.package-as-mod.mod-loaders": {
|
||||
"message": "Mod loaders"
|
||||
},
|
||||
"version.package-as-mod.mod-loaders.description": {
|
||||
"message": "The mod loaders you would like to package your data pack for."
|
||||
},
|
||||
"version.package-as-mod.mod-loaders.placeholder": {
|
||||
"message": "Choose mod loaders..."
|
||||
},
|
||||
"version.package-as-mod.submit-button": {
|
||||
"message": "Package data pack"
|
||||
},
|
||||
"version.section.content.dev-info": {
|
||||
"message": "Developer information"
|
||||
},
|
||||
"version.section.content.dev-info.gradle-snippet": {
|
||||
"message": "build.gradle:"
|
||||
},
|
||||
"version.section.content.dev-info.maven-coordinates": {
|
||||
"message": "Maven coordinates:"
|
||||
},
|
||||
"version.section.content.dev-info.maven-description": {
|
||||
"message": "Projects on Modrinth are automatically available through a Maven repository for use with JVM build tools such as <gradle-link>Gradle</gradle-link>. To learn more about the Modrinth Maven API, <article-link>click here</article-link>."
|
||||
},
|
||||
"version.section.content.dev-info.maven-note": {
|
||||
"message": "Note: When available, you should use the creator's maven repo instead as it will have transitive dependency information that the Modrinth Maven API does not. You may also end up with duplicate dependencies if you use a mix of Modrinth and non-Modrinth Maven repositories for your dependencies, because the group identifier will be different when served through the Modrinth Maven API."
|
||||
},
|
||||
"version.section.content.dev-info.version-id": {
|
||||
"message": "Version ID:"
|
||||
},
|
||||
"version.supplementary-resources.copy-hash-sha1": {
|
||||
"message": "Copy SHA-1"
|
||||
},
|
||||
"version.supplementary-resources.copy-hash-sha512": {
|
||||
"message": "Copy SHA-512"
|
||||
},
|
||||
"version.unknown-embedded-content.description": {
|
||||
"message": "This version is currently withheld and not publicly listed. Please provide proof that you have permission to redistribute certain files included."
|
||||
},
|
||||
"version.unknown-embedded-content.title": {
|
||||
"message": "Withheld due to unknown embedded content"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3290,9 +3290,6 @@
|
||||
"project.moderation.thread.help-center-note.2": {
|
||||
"message": "Si necesitas ayuda o tienes consultas adicionales, por favor visita el <help-center-link>Centro de ayuda de Modrinth</help-center-link> y haz click en la burbuja azúl para contactar con soporte."
|
||||
},
|
||||
"project.moderation.thread.moderator-see-user-ui-toggle": {
|
||||
"message": "Mostrar interfaz de miembros"
|
||||
},
|
||||
"project.moderation.thread.private-description": {
|
||||
"message": "Este es un hilo de conversación con los moderadores de Modrinth. Es posible que te envíen mensajes sobre cuestiones relacionadas con este proyecto."
|
||||
},
|
||||
@@ -3422,9 +3419,6 @@
|
||||
"project.versions.withheld-versions-warning.description": {
|
||||
"message": "{count, plural, one {Esta versión está retenida y no listada} other {Estas versiones están retenidas y no listadas}} públicamente. Por favor, proporciona pruebas de que tienes permiso para redistribuir algunos de los archivos incluidos en {count, plural, one {la versión} other {las versiones}} del modpack."
|
||||
},
|
||||
"project.versions.withheld-versions-warning.resolve-button": {
|
||||
"message": "Resolver"
|
||||
},
|
||||
"project.versions.withheld-versions-warning.title": {
|
||||
"message": "{count, plural, one {Versión {version_name} retenida} other {Versiones retenidas}} debido a que incluye contenido desconocido"
|
||||
},
|
||||
|
||||
@@ -3284,9 +3284,6 @@
|
||||
"project.moderation.thread.help-center-note.2": {
|
||||
"message": "Si necesitas ayuda o tienes consultas adicionales, por favor visita el <help-center-link>Centro de ayuda de Modrinth</help-center-link> y haz click en la burbuja azul para contactar con soporte."
|
||||
},
|
||||
"project.moderation.thread.moderator-see-user-ui-toggle": {
|
||||
"message": "Mostrar interfaz de miembros"
|
||||
},
|
||||
"project.moderation.thread.private-description": {
|
||||
"message": "Este es un hilo de conversación privado con los moderadores de Modrinth. Es posible que te envíen mensajes sobre cuestiones relacionadas con este proyecto."
|
||||
},
|
||||
@@ -3416,9 +3413,6 @@
|
||||
"project.versions.withheld-versions-warning.description": {
|
||||
"message": "{count, plural, one {Esta versión está retenida y no listada} other {Estas versiones están retenidas y no listadas}} públicamente. Por favor, proporciona pruebas de que tienes permiso para redistribuir algunos de los archivos incluidos en {count, plural, one {la versión} other {las versiones}} del modpack."
|
||||
},
|
||||
"project.versions.withheld-versions-warning.resolve-button": {
|
||||
"message": "Resolver"
|
||||
},
|
||||
"project.versions.withheld-versions-warning.title": {
|
||||
"message": "{count, plural, one {Versión {version_name} retenida} other {Versiones retenidas}} debido a que incluye contenido desconocido"
|
||||
},
|
||||
|
||||
@@ -3287,9 +3287,6 @@
|
||||
"project.moderation.thread.help-center-note.2": {
|
||||
"message": "Si vous avez besoin d'aide ou si vous avez des demandes de renseignements supplémentaires, veuillez visiter le <help-center-link>Modrinth Help Center</help-center-link> et cliquez sur la bulle bleue pour contacter le support."
|
||||
},
|
||||
"project.moderation.thread.moderator-see-user-ui-toggle": {
|
||||
"message": "Montrer l'interface des membres"
|
||||
},
|
||||
"project.moderation.thread.private-description": {
|
||||
"message": "Il s'agit d'un fil de conversation privé avec les modérateurs du Modrinthe. Ils peuvent vous envoyer un message avec des problèmes concernant ce projet."
|
||||
},
|
||||
@@ -3419,9 +3416,6 @@
|
||||
"project.versions.withheld-versions-warning.description": {
|
||||
"message": "{count, plural, one {Cette version est actuellement indisponible et n'est pas répertoriée} other {Ces versions sont actuellement indisponibles et n'ont pas été répertoriées}} publiquement. Veuillez fournir la preuve que vous avez l'autorisation de redistribuer certains fichiers inclus dans {count, plural, one {la version} other {les versions}} du modpack."
|
||||
},
|
||||
"project.versions.withheld-versions-warning.resolve-button": {
|
||||
"message": "Résoudre"
|
||||
},
|
||||
"project.versions.withheld-versions-warning.title": {
|
||||
"message": "{count, plural, one {Version {version_name} non disponible} other {Versions non disponibles}} en raison de contenus intégrés inconnus"
|
||||
},
|
||||
|
||||
@@ -3113,9 +3113,6 @@
|
||||
"project.versions.withheld-versions-warning.description": {
|
||||
"message": "{count, plural, one {Ez a verzió} other {Ezek a verziók}} jelenleg vissza van tartva, és nem érhető{count, plural, one {} other {k}} el nyilvánosan. Kérjük, igazold, hogy engedéllyel rendelkezel a modcsomag {count, plural, one {verziójában} other {verzióiban}} található bizonyos fájlok terjesztésére."
|
||||
},
|
||||
"project.versions.withheld-versions-warning.resolve-button": {
|
||||
"message": "Megoldás"
|
||||
},
|
||||
"project.versions.withheld-versions-warning.title": {
|
||||
"message": "{count, plural, one {A(z) {version_name} verzió} other {A verziók}} ismeretlen beágyazott tartalom miatt vissza lett{count, plural, one {} other {ek}} tartva"
|
||||
},
|
||||
|
||||
@@ -3266,9 +3266,6 @@
|
||||
"project.moderation.thread.help-center-note.2": {
|
||||
"message": "Se hai bisogno di assistenza o hai altre richieste, visita il <help-center-link>centro assistenza di Modrinth</help-center-link> e clicca sulla bolla blu nell'angolo in basso a destra."
|
||||
},
|
||||
"project.moderation.thread.moderator-see-user-ui-toggle": {
|
||||
"message": "Mostra interfaccia degli utenti"
|
||||
},
|
||||
"project.moderation.thread.private-description": {
|
||||
"message": "Questa è una conversazione privata con i moderatori di Modrinth. Sarai contattato per eventuali problemi riguardanti questo progetto."
|
||||
},
|
||||
@@ -3389,9 +3386,6 @@
|
||||
"project.versions.withheld-versions-warning.description": {
|
||||
"message": "{count, plural, one {Questa versione è attualmente sospesa e non elencata} other {Queste versioni sono attualmente sospese e non elencate}} pubblicamente. Devi dimostrare di avere il permesso di ridistribuire alcuni dei file presenti in {count, plural, one {questa versione} other {queste versioni}} del pacchetto."
|
||||
},
|
||||
"project.versions.withheld-versions-warning.resolve-button": {
|
||||
"message": "Risolvi"
|
||||
},
|
||||
"project.versions.withheld-versions-warning.title": {
|
||||
"message": "{count, plural, one {La versione {version_name} è stata sospesa} other {Alcune versioni sono state sospese}} per attribuzioni incomplete"
|
||||
},
|
||||
|
||||
@@ -2750,9 +2750,6 @@
|
||||
"project.moderation.thread.help-center-note.2": {
|
||||
"message": "도움이 필요하거나 추가 문의 사항이 있다면 <help-center-link>Modrinth 도움말 센터</help-center-link>를 방문하여 파란색 말풍선을 클릭해 지원 센터에 문의해 주세요."
|
||||
},
|
||||
"project.moderation.thread.moderator-see-user-ui-toggle": {
|
||||
"message": "멤버 UI 표시"
|
||||
},
|
||||
"project.moderation.thread.private-description": {
|
||||
"message": "이곳은 Modrinth 운영진과의 비공개 대화 스레드입니다. 운영진이 이 프로젝트와 관련된 사항에 대해 메시지를 보낼 수 있습니다."
|
||||
},
|
||||
@@ -2870,9 +2867,6 @@
|
||||
"project.versions.title": {
|
||||
"message": "버전"
|
||||
},
|
||||
"project.versions.withheld-versions-warning.resolve-button": {
|
||||
"message": "해결"
|
||||
},
|
||||
"report.already-reported": {
|
||||
"message": "이미 {title} 을(를) 신고했습니다"
|
||||
},
|
||||
|
||||
@@ -3089,9 +3089,6 @@
|
||||
"project.moderation.thread.help-center-note.2": {
|
||||
"message": "Jika anda memerlukan bantuan atau mempunyai pertanyaan tambahan, sila lawati <help-center-link>Pusat Bantuan Modrinth</help-center-link> dan klik gelembung biru untuk menghubungi sokongan."
|
||||
},
|
||||
"project.moderation.thread.moderator-see-user-ui-toggle": {
|
||||
"message": "Tunjukkan antara muka ahli"
|
||||
},
|
||||
"project.moderation.thread.private-description": {
|
||||
"message": "Ini adalah bebenang perbualan peribadi dengan penyederhana Modrinth. Mereka mungkin akan menghantar mesej kepada anda tentang isu-isu berkaitan projek ini."
|
||||
},
|
||||
@@ -3209,9 +3206,6 @@
|
||||
"project.versions.title": {
|
||||
"message": "Versi"
|
||||
},
|
||||
"project.versions.withheld-versions-warning.resolve-button": {
|
||||
"message": "Selesaikan"
|
||||
},
|
||||
"report.already-reported": {
|
||||
"message": "Anda telah melaporkan {title}"
|
||||
},
|
||||
|
||||
@@ -3209,9 +3209,6 @@
|
||||
"project.moderation.thread.help-center-note.2": {
|
||||
"message": "Jeżeli potrzebujesz pomocy lub masz więcej pytań, odwiedź <help-center-link>centrum pomocy Modrinth</help-center-link> i kliknij w niebieskie kółko, by skontaktować się z obsługą."
|
||||
},
|
||||
"project.moderation.thread.moderator-see-user-ui-toggle": {
|
||||
"message": "Pokaż UI członków"
|
||||
},
|
||||
"project.moderation.thread.private-description": {
|
||||
"message": "Prywatny wątek konwersacji z moderatorami Modrinth. Mogą skontaktować się z Tobą w razie problemów z Twoim projektem."
|
||||
},
|
||||
@@ -3338,9 +3335,6 @@
|
||||
"project.versions.withheld-versions-warning.description": {
|
||||
"message": "{count, plural, one {Ta wersja jest} other {Te wersje są}} obecnie wstrzymane i nie widnieją na publicznej liście. Proszę o przesłanie potwierdzenia posiadania zgody na redystrybucję określonych plików zawartych w {count, plural, one {tej wersji} other {tych wersjach}} modpacka."
|
||||
},
|
||||
"project.versions.withheld-versions-warning.resolve-button": {
|
||||
"message": "Rozwiąż"
|
||||
},
|
||||
"project.versions.withheld-versions-warning.title": {
|
||||
"message": "{count, plural, one {Wersja {version_name}} other {Wersje}} wstrzymane ze względu na nieznaną osadzoną zawartość"
|
||||
},
|
||||
|
||||
@@ -3278,9 +3278,6 @@
|
||||
"project.moderation.thread.help-center-note.2": {
|
||||
"message": "Se precisar de ajuda ou tiver dúvidas adicionais, visite a <help-center-link>Central de Ajuda do Modrinth</help-center-link> e clique no balão azul para entrar em contato com o suporte."
|
||||
},
|
||||
"project.moderation.thread.moderator-see-user-ui-toggle": {
|
||||
"message": "Exibir interface do membro"
|
||||
},
|
||||
"project.moderation.thread.private-description": {
|
||||
"message": "Este é um tópico de conversa privada com os moderadores do Modrinth. Eles podem entrar em contato com você para tratar de assuntos relacionados a este projeto."
|
||||
},
|
||||
@@ -3404,9 +3401,6 @@
|
||||
"project.versions.withheld-versions-warning.description": {
|
||||
"message": "{count, plural, one {Esta versão está atualmente retida e não listada} other {Estas versões estão atualmente retidas e não listadas}} publicamente. Por favor, forneça prova de que você tem permissão para redistribuir certos arquivos incluídos {count, plural, one {na versão} other {nas versões}} do pacote de mods."
|
||||
},
|
||||
"project.versions.withheld-versions-warning.resolve-button": {
|
||||
"message": "Resolver"
|
||||
},
|
||||
"project.versions.withheld-versions-warning.title": {
|
||||
"message": "{count, plural,one {Versão {version_name} retida} other {Versões retidas}} por conteúdo incluso desconhecido"
|
||||
},
|
||||
|
||||
@@ -3233,9 +3233,6 @@
|
||||
"project.moderation.thread.help-center-note.2": {
|
||||
"message": "Если вам нужна помощь или у вас есть дополнительные вопросы, посетите <help-center-link>справочный центр Modrinth</help-center-link> и нажмите на синий значок, чтобы обратиться в поддержку."
|
||||
},
|
||||
"project.moderation.thread.moderator-see-user-ui-toggle": {
|
||||
"message": "Показать интерфейс участника"
|
||||
},
|
||||
"project.moderation.thread.private-description": {
|
||||
"message": "Это приватная переписка с модераторами Modrinth. Они могут связываться с вами по вопросам, связанным с этим проектом."
|
||||
},
|
||||
@@ -3359,9 +3356,6 @@
|
||||
"project.versions.withheld-versions-warning.description": {
|
||||
"message": "{count, plural, one {Эта версия в настоящее время скрыта и не отображается} other {Эти версии в настоящее время скрыты и не отображаются}} в публичном списке. Пожалуйста, предоставьте доказательства того, что у вас есть разрешение на распространение определённых файлов, включённых в {count, plural, one {эту версию} other {эти версии}}."
|
||||
},
|
||||
"project.versions.withheld-versions-warning.resolve-button": {
|
||||
"message": "Решить"
|
||||
},
|
||||
"project.versions.withheld-versions-warning.title": {
|
||||
"message": "{count, plural, one {Версия {version_name} скрыта} other {Версии скрыты}} из-за неизвестного встроенного контента"
|
||||
},
|
||||
|
||||
@@ -3275,9 +3275,6 @@
|
||||
"project.moderation.thread.help-center-note.2": {
|
||||
"message": "Yardıma ihtiyacınız varsa veya ek sorularınız varsa, lütfen <help-center-link>Modrinth Yardım Merkezi'ni</help-center-link> ziyaret edin ve destekle iletişime geçmek için mavi baloncuğa tıklayın."
|
||||
},
|
||||
"project.moderation.thread.moderator-see-user-ui-toggle": {
|
||||
"message": "Üye kullanıcı arayüzünü göster"
|
||||
},
|
||||
"project.moderation.thread.private-description": {
|
||||
"message": "Bu, Modrinth moderatörleri ile özel bir konuşma başlığıdır. Bu projeyle ilgili konularda size mesaj gönderebilirler."
|
||||
},
|
||||
@@ -3407,9 +3404,6 @@
|
||||
"project.versions.withheld-versions-warning.description": {
|
||||
"message": "{count, plural, one {Bu sürüm} other {Bu sürümler}} şu anda gizlenmiş durumda ve herkese açık olarak listelenmiyor. Lütfen {count, plural, one {sürüm} other {sürümler}} mod paketine dahil edilen belirli dosyaları yeniden dağıtma izniniz olduğuna dair kanıt sunun."
|
||||
},
|
||||
"project.versions.withheld-versions-warning.resolve-button": {
|
||||
"message": "Çöz"
|
||||
},
|
||||
"project.versions.withheld-versions-warning.title": {
|
||||
"message": "{count, plural, one {Sürüm {version_name}} other {Sürümler}} bilinmeyen gömülü içerik nedeniyle gizlendi"
|
||||
},
|
||||
|
||||
@@ -2975,9 +2975,6 @@
|
||||
"project.moderation.thread.help-center-note.2": {
|
||||
"message": "Якщо вам потрібна допомога або у вас є додаткові запитання, відвідайте <help-center-link>довідковий центр Modrinth</help-center-link> і натисніть синю підказку, щоб зв’язатися зі службою підтримки."
|
||||
},
|
||||
"project.moderation.thread.moderator-see-user-ui-toggle": {
|
||||
"message": "Показати інтерфейс учасника"
|
||||
},
|
||||
"project.moderation.thread.private-description": {
|
||||
"message": "Це тема приватної розмови з модераторами Modrinth. Вони можуть надіслати вам повідомлення про проблеми щодо цього проєкту."
|
||||
},
|
||||
@@ -3095,9 +3092,6 @@
|
||||
"project.versions.title": {
|
||||
"message": "Версії"
|
||||
},
|
||||
"project.versions.withheld-versions-warning.resolve-button": {
|
||||
"message": "Розв'язати"
|
||||
},
|
||||
"project.versions.withheld-versions-warning.title": {
|
||||
"message": "{count, plural, one {Версія{version_name}} other {Версії}} утримано через невідомий убудований уміст"
|
||||
},
|
||||
|
||||
@@ -3200,9 +3200,6 @@
|
||||
"project.moderation.thread.help-center-note.2": {
|
||||
"message": "Nếu bạn cần trợ giúp hoặc có thắc mắc, vui lòng vào <help-center-link>trung tâm trợ giúp</help-center-link> và bấm vào nút tin nhắn màu xanh để liên hệ hỗ trợ."
|
||||
},
|
||||
"project.moderation.thread.moderator-see-user-ui-toggle": {
|
||||
"message": "Hiển thị giao diện thành viên"
|
||||
},
|
||||
"project.moderation.thread.private-description": {
|
||||
"message": "Đây là nơi hội thoại riêng tư với bên kiểm duyệt của Modrinth. Họ có thể nhắn cho bạn về những vấn đề trong dự án này."
|
||||
},
|
||||
@@ -3320,9 +3317,6 @@
|
||||
"project.versions.title": {
|
||||
"message": "Phiên bản"
|
||||
},
|
||||
"project.versions.withheld-versions-warning.resolve-button": {
|
||||
"message": "Khắc phục"
|
||||
},
|
||||
"report.already-reported": {
|
||||
"message": "Bạn đã báo cáo {title}"
|
||||
},
|
||||
|
||||
@@ -3254,9 +3254,6 @@
|
||||
"project.moderation.thread.help-center-note.2": {
|
||||
"message": "如果你需要帮助或有其他疑问,请访问 <help-center-link>Modrinth Help Center</help-center-link>,然后点击蓝色气泡以联系支持。"
|
||||
},
|
||||
"project.moderation.thread.moderator-see-user-ui-toggle": {
|
||||
"message": "显示成员图形界面"
|
||||
},
|
||||
"project.moderation.thread.private-description": {
|
||||
"message": "这是与 Modrinth 管理员的私人对话消息。他们可能会就此项目的问题向你发送消息。"
|
||||
},
|
||||
@@ -3380,9 +3377,6 @@
|
||||
"project.versions.withheld-versions-warning.description": {
|
||||
"message": "{count, plural, one {此版本已保留且未公开列出。} other {这些版本已保留且未公开列出。}} 请提供证明,证明你有权重新分发该模组包{count, plural, one {版本} other {各版本}}中包含的某些文件。"
|
||||
},
|
||||
"project.versions.withheld-versions-warning.resolve-button": {
|
||||
"message": "完成"
|
||||
},
|
||||
"project.versions.withheld-versions-warning.title": {
|
||||
"message": "{count, plural, one {版本 {version_name}} other {这些版本}} 因未知嵌入内容被保留"
|
||||
},
|
||||
|
||||
@@ -3134,9 +3134,6 @@
|
||||
"project.moderation.admonition.withheld.header": {
|
||||
"message": "由工作人員設為不公開"
|
||||
},
|
||||
"project.moderation.thread.moderator-see-user-ui-toggle": {
|
||||
"message": "顯示成員 UI"
|
||||
},
|
||||
"project.moderation.thread.title": {
|
||||
"message": "審核訊息"
|
||||
},
|
||||
@@ -3254,9 +3251,6 @@
|
||||
"project.versions.withheld-versions-warning.description": {
|
||||
"message": "{count, plural, one {這個版本} other {這些版本}}目前被限制存取且不公開。請提供你擁有轉載該模組包{count, plural, other {版本}}版本中特定檔案的許可證明。"
|
||||
},
|
||||
"project.versions.withheld-versions-warning.resolve-button": {
|
||||
"message": "解決"
|
||||
},
|
||||
"project.versions.withheld-versions-warning.title": {
|
||||
"message": "{count, plural, one {版本 {version_name}} other {版本}}因未知的嵌入內容而被限制存取"
|
||||
},
|
||||
|
||||
@@ -881,7 +881,9 @@
|
||||
class="card flex-card"
|
||||
/>
|
||||
<ProjectSidebarCompatibility
|
||||
v-if="projectV3Loaded && !isServerProject"
|
||||
v-if="
|
||||
projectV3Loaded && !isServerProject && route.name !== 'type-project-version-version'
|
||||
"
|
||||
:project="project"
|
||||
:tags="tags"
|
||||
:project-v3="projectV3"
|
||||
@@ -1123,7 +1125,7 @@ import MessageBanner from '~/components/ui/MessageBanner.vue'
|
||||
import ModerationChecklist from '~/components/ui/moderation/checklist/ModerationChecklist.vue'
|
||||
import ModerationProjectNags from '~/components/ui/moderation/ModerationProjectNags.vue'
|
||||
import ProjectMemberHeader from '~/components/ui/ProjectMemberHeader.vue'
|
||||
import { getSignInRouteObj } from '~/composables/auth.js'
|
||||
import { getSignInRouteObj } from '~/composables/auth.ts'
|
||||
import { saveFeatureFlags } from '~/composables/featureFlags.ts'
|
||||
import { STALE_TIME, STALE_TIME_LONG } from '~/composables/queries/project'
|
||||
import { versionQueryOptions } from '~/composables/queries/version'
|
||||
|
||||
@@ -72,11 +72,9 @@
|
||||
<h2 id="messages" class="m-0 text-xl font-semibold text-contrast">
|
||||
{{ formatMessage(messages.threadSectionTitle) }}
|
||||
</h2>
|
||||
<div v-if="currentMember?.staffOnly" class="flex items-center gap-2">
|
||||
<div v-if="isStaff(currentMember?.user)" class="flex items-center gap-2">
|
||||
<Toggle id="moderator-see-user-ui-toggle" v-model="moderatorSeeUserUi" small />
|
||||
<label for="moderator-see-user-ui-toggle">
|
||||
{{ formatMessage(messages.moderatorSeeUserUiToggle) }}
|
||||
</label>
|
||||
<label for="moderator-see-user-ui-toggle"> Show member UI </label>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="userFacingUiVisible">
|
||||
@@ -151,8 +149,9 @@ import {
|
||||
Toggle,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { isStaff } from '@modrinth/utils'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, type Ref, watch } from 'vue'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import ConversationThread from '~/components/ui/thread/ConversationThread.vue'
|
||||
import { getProjectLink, isApproved, isRejected, isUnderReview } from '~/helpers/projects.js'
|
||||
@@ -160,7 +159,6 @@ import { getProjectLink, isApproved, isRejected, isUnderReview } from '~/helpers
|
||||
const { formatMessage } = useVIntl()
|
||||
const flags = useFeatureFlags()
|
||||
|
||||
type ProjectPageMember = Labrinth.Projects.v3.TeamMember & { staffOnly?: boolean }
|
||||
type ModerationAdmonitionSection =
|
||||
| {
|
||||
type: 'paragraph'
|
||||
@@ -181,10 +179,6 @@ const messages = defineMessages({
|
||||
id: 'project.moderation.thread.title',
|
||||
defaultMessage: 'Moderation messages',
|
||||
},
|
||||
moderatorSeeUserUiToggle: {
|
||||
id: 'project.moderation.thread.moderator-see-user-ui-toggle',
|
||||
defaultMessage: 'Show member UI',
|
||||
},
|
||||
threadPrivateDescription: {
|
||||
id: 'project.moderation.thread.private-description',
|
||||
defaultMessage:
|
||||
@@ -213,18 +207,10 @@ const messages = defineMessages({
|
||||
})
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const {
|
||||
projectV2: project,
|
||||
currentMember: currentMemberRaw,
|
||||
invalidate,
|
||||
allMembers,
|
||||
} = injectProjectPageContext()
|
||||
const currentMember = currentMemberRaw as Ref<ProjectPageMember | null>
|
||||
const { projectV2: project, currentMember, invalidate, allMembers } = injectProjectPageContext()
|
||||
|
||||
const canAccess = computed(() => !!currentMember.value)
|
||||
const userFacingUiVisible = computed(
|
||||
() => !!currentMember.value && (!currentMember.value.staffOnly || moderatorSeeUserUi.value),
|
||||
)
|
||||
const userFacingUiVisible = computed(() => !!currentMember.value && moderatorSeeUserUi.value)
|
||||
|
||||
const approvedAdmonitionMessage = computed<MessageDescriptor | null>(() => {
|
||||
switch (project.value?.status) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
commonMessages,
|
||||
commonProjectSettingsMessages,
|
||||
injectProjectPageContext,
|
||||
Toggle,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { isStaff } from '@modrinth/utils'
|
||||
@@ -47,10 +48,8 @@ const navItems = computed(() => {
|
||||
projectV3.value?.project_types?.some((type) => ['mod', 'modpack'].includes(type)) &&
|
||||
isStaff(currentMember.value?.user)
|
||||
|
||||
const hasPermissionsPage = computed(
|
||||
() =>
|
||||
flags.value.modpackPermissionsPage &&
|
||||
projectV3.value?.project_types?.some((type) => ['modpack'].includes(type)),
|
||||
const hasPermissionsPage = computed(() =>
|
||||
projectV3.value?.project_types?.some((type) => ['modpack'].includes(type)),
|
||||
)
|
||||
|
||||
const items = [
|
||||
@@ -82,16 +81,16 @@ const navItems = computed(() => {
|
||||
label: formatMessage(commonProjectSettingsMessages.description),
|
||||
icon: AlignLeftIcon,
|
||||
},
|
||||
hasPermissionsPage.value && {
|
||||
link: `/${base}/settings/permissions`,
|
||||
label: formatMessage(commonProjectSettingsMessages.permissions),
|
||||
icon: SignatureIcon,
|
||||
},
|
||||
!isServerProject.value && {
|
||||
link: `/${base}/settings/versions`,
|
||||
label: formatMessage(commonProjectSettingsMessages.versions),
|
||||
icon: VersionIcon,
|
||||
},
|
||||
hasPermissionsPage.value && {
|
||||
link: `/${base}/settings/permissions`,
|
||||
label: formatMessage(commonProjectSettingsMessages.permissions),
|
||||
icon: SignatureIcon,
|
||||
},
|
||||
!isServerProject.value && {
|
||||
link: `/${base}/settings/license`,
|
||||
label: formatMessage(commonProjectSettingsMessages.license),
|
||||
@@ -144,6 +143,16 @@ watch(route, () => {
|
||||
const scrollY = scroll.y.value
|
||||
setTimeout(() => window.scrollTo(0, scrollY), 10)
|
||||
})
|
||||
|
||||
const moderatorSeeUserUi = computed<boolean>({
|
||||
get() {
|
||||
return flags.value.showModeratorProjectMemberUi
|
||||
},
|
||||
set(value: boolean) {
|
||||
flags.value.showModeratorProjectMemberUi = value
|
||||
saveFeatureFlags()
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -167,6 +176,10 @@ watch(route, () => {
|
||||
<div class="grid gap-4 lg:grid-cols-[1fr_3fr]">
|
||||
<div>
|
||||
<NavStack :items="navItems" />
|
||||
<div v-if="isStaff(currentMember?.user)" class="mt-4 flex items-center gap-2">
|
||||
<Toggle id="moderator-see-user-ui-toggle" v-model="moderatorSeeUserUi" small />
|
||||
<label for="moderator-see-user-ui-toggle"> Show member UI </label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<NuxtPage />
|
||||
|
||||
@@ -1,51 +1,303 @@
|
||||
<script setup lang="ts">
|
||||
import { RightArrowIcon, SearchIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
ArrowDown10Icon,
|
||||
ArrowDownWideNarrowIcon,
|
||||
ClockArrowDownIcon,
|
||||
FoldVerticalIcon,
|
||||
PauseIcon,
|
||||
RightArrowIcon,
|
||||
ScaleIcon,
|
||||
SearchIcon,
|
||||
UnfoldVerticalIcon,
|
||||
XCircleIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
commonMessages,
|
||||
createAttributionGroupTitle,
|
||||
defineMessage,
|
||||
defineMessages,
|
||||
EmptyState,
|
||||
ExternalProjectPermissionsCard,
|
||||
injectModrinthClient,
|
||||
injectProjectPageContext,
|
||||
IntlFormatted,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import ExternalProjectPermissionsCard from '@modrinth/ui/src/components/external_files/ExternalProjectPermissionsCard.vue'
|
||||
import { ref } from 'vue'
|
||||
import { isStaff } from '@modrinth/utils'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
import { setupAttributionModerationProvider } from '~/providers/setup/attribution-moderation'
|
||||
|
||||
setupAttributionModerationProvider()
|
||||
|
||||
const auth = await useAuth()
|
||||
const flags = useFeatureFlags()
|
||||
|
||||
if (!flags.value.modpackPermissionsPage) {
|
||||
throw createError({
|
||||
fatal: true,
|
||||
statusCode: 404,
|
||||
})
|
||||
const isModerator = computed(() => {
|
||||
return isStaff(auth.value?.user) && !flags.value.showModeratorProjectMemberUi
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { projectV2: project } = injectProjectPageContext()
|
||||
const { labrinth } = injectModrinthClient()
|
||||
|
||||
type SortType = 'status' | 'most_files' | 'recently_edited' | 'rejected'
|
||||
|
||||
const searchQuery = ref('')
|
||||
const currentSortType = ref<SortType>('status')
|
||||
const cardCollapsedById = ref<Record<string, boolean>>({})
|
||||
|
||||
const {
|
||||
data: attributionData,
|
||||
error: attributionError,
|
||||
isPending: pending,
|
||||
} = useQuery<Labrinth.Attribution.Internal.AttributionGroup[]>({
|
||||
queryKey: ['project-attribution', project.value.id],
|
||||
queryFn: () => labrinth.attribution_internal.listProjectAttribution(project.value.id),
|
||||
})
|
||||
|
||||
const sortRejectedLabel = defineMessage({
|
||||
id: 'project.settings.permissions.sort.rejected',
|
||||
defaultMessage: 'Rejected',
|
||||
})
|
||||
|
||||
const sortTypes = computed<ComboboxOption<SortType>[]>(() => {
|
||||
const options: ComboboxOption<SortType>[] = [
|
||||
{
|
||||
value: 'status',
|
||||
label: formatMessage(
|
||||
defineMessage({
|
||||
id: 'project.settings.permissions.sort.status',
|
||||
defaultMessage: 'Status',
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'most_files',
|
||||
label: formatMessage(
|
||||
defineMessage({
|
||||
id: 'project.settings.permissions.sort.most-files',
|
||||
defaultMessage: 'Most files',
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'recently_edited',
|
||||
label: formatMessage(
|
||||
defineMessage({
|
||||
id: 'project.settings.permissions.sort.recently-edited',
|
||||
defaultMessage: 'Recently edited',
|
||||
}),
|
||||
),
|
||||
},
|
||||
]
|
||||
if (hasMixedModerationStatus.value) {
|
||||
options.push({
|
||||
value: 'rejected',
|
||||
label: formatMessage(sortRejectedLabel),
|
||||
})
|
||||
}
|
||||
return options
|
||||
})
|
||||
|
||||
const currentSortLabel = computed(() => {
|
||||
return sortTypes.value.find((option) => option.value === currentSortType.value)?.label ?? ''
|
||||
})
|
||||
|
||||
function isAttributed(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
|
||||
return !!group.attribution && !isNoPermission(group)
|
||||
}
|
||||
|
||||
const externalFiles = ref([{}])
|
||||
const searchQuery = ref('')
|
||||
const currentSortType = ref('Oldest')
|
||||
function isNoPermission(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
|
||||
return group.attribution?.kind === 'no_permission'
|
||||
}
|
||||
|
||||
function isModerationRejected(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
|
||||
const kind = group.attribution?.moderation_status?.kind
|
||||
return kind === 'bad_proof' || kind === 'not_allowed'
|
||||
}
|
||||
|
||||
function isNotAllowed(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
|
||||
return group.attribution?.moderation_status?.kind === 'not_allowed'
|
||||
}
|
||||
|
||||
function isBadProof(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
|
||||
return group.attribution?.moderation_status?.kind === 'bad_proof'
|
||||
}
|
||||
|
||||
function needsModerationApproval(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
|
||||
const attribution = group.attribution
|
||||
if (!attribution || attribution.kind === 'globally_allowed') {
|
||||
return false
|
||||
}
|
||||
return !attribution.moderation_status
|
||||
}
|
||||
|
||||
function rejectedSortRank(group: Labrinth.Attribution.Internal.AttributionGroup): number {
|
||||
if (isModerationRejected(group)) {
|
||||
return 0
|
||||
}
|
||||
if (group.attribution?.moderation_status?.kind === 'approved') {
|
||||
return 1
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
const hasMixedModerationStatus = computed(() => {
|
||||
const groups = attributionData.value ?? []
|
||||
let hasRejected = false
|
||||
let hasNonRejected = false
|
||||
for (const group of groups) {
|
||||
if (isModerationRejected(group)) {
|
||||
hasRejected = true
|
||||
} else {
|
||||
hasNonRejected = true
|
||||
}
|
||||
if (hasRejected && hasNonRejected) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
watch(hasMixedModerationStatus, (mixed) => {
|
||||
if (!mixed && currentSortType.value === 'rejected') {
|
||||
currentSortType.value = 'status'
|
||||
}
|
||||
})
|
||||
|
||||
function statusSortRank(group: Labrinth.Attribution.Internal.AttributionGroup): number {
|
||||
if (isNoPermission(group)) {
|
||||
return 0
|
||||
} else if (!group.attribution) {
|
||||
return 1
|
||||
} else if (group.attribution?.kind === 'globally_allowed') {
|
||||
return 3
|
||||
} else {
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
function alphabetSortKey(group: Labrinth.Attribution.Internal.AttributionGroup): string {
|
||||
return createAttributionGroupTitle(group, formatMessage)
|
||||
}
|
||||
|
||||
function compareAlphabetical(
|
||||
a: Labrinth.Attribution.Internal.AttributionGroup,
|
||||
b: Labrinth.Attribution.Internal.AttributionGroup,
|
||||
): number {
|
||||
return alphabetSortKey(a).localeCompare(alphabetSortKey(b), undefined, { sensitivity: 'base' })
|
||||
}
|
||||
|
||||
function attributedTimestamp(group: Labrinth.Attribution.Internal.AttributionGroup): number {
|
||||
if (!group.attributed_at) return Number.NEGATIVE_INFINITY
|
||||
const t = Date.parse(group.attributed_at)
|
||||
return Number.isNaN(t) ? Number.NEGATIVE_INFINITY : t
|
||||
}
|
||||
|
||||
function groupMatchesPermissionsSearch(
|
||||
group: Labrinth.Attribution.Internal.AttributionGroup,
|
||||
queryTrimmed: string,
|
||||
): boolean {
|
||||
const query = queryTrimmed.toLowerCase()
|
||||
if (group.flame_project?.title?.toLowerCase().includes(query)) {
|
||||
return true
|
||||
}
|
||||
return (group.files ?? []).some((file) => file.name.toLowerCase().includes(query))
|
||||
}
|
||||
|
||||
const filteredGroups = computed(() => {
|
||||
const groups = attributionData.value ?? []
|
||||
const queryTrimmed = searchQuery.value.trim()
|
||||
const filtered = queryTrimmed
|
||||
? groups.filter((group) => groupMatchesPermissionsSearch(group, queryTrimmed))
|
||||
: [...groups]
|
||||
const sortMode = currentSortType.value
|
||||
filtered.sort(compareAlphabetical)
|
||||
filtered.sort((a, b) => {
|
||||
if (sortMode === 'status') {
|
||||
return statusSortRank(a) - statusSortRank(b)
|
||||
}
|
||||
if (sortMode === 'most_files') {
|
||||
const ac = a.files?.length ?? 0
|
||||
const bc = b.files?.length ?? 0
|
||||
return bc - ac
|
||||
}
|
||||
if (sortMode === 'rejected') {
|
||||
return rejectedSortRank(a) - rejectedSortRank(b)
|
||||
}
|
||||
const at = attributedTimestamp(a)
|
||||
const bt = attributedTimestamp(b)
|
||||
return bt - at
|
||||
})
|
||||
return filtered
|
||||
})
|
||||
|
||||
const totalGroups = computed(() => attributionData.value?.length ?? 0)
|
||||
|
||||
const stats = computed(() => {
|
||||
const groups = attributionData.value ?? []
|
||||
let attributed = 0
|
||||
let pending = 0
|
||||
let noPermission = 0
|
||||
for (const group of groups) {
|
||||
if (isNoPermission(group)) {
|
||||
noPermission++
|
||||
} else if (isAttributed(group)) {
|
||||
attributed++
|
||||
} else {
|
||||
pending++
|
||||
}
|
||||
}
|
||||
return { total: groups.length, attributed, pending, noPermission }
|
||||
})
|
||||
|
||||
const pendingApprovalCount = computed(() => {
|
||||
const groups = attributionData.value ?? []
|
||||
return groups.filter(needsModerationApproval).length
|
||||
})
|
||||
|
||||
const noPermissionCount = computed(() => {
|
||||
const groups = attributionData.value ?? []
|
||||
return groups.filter((group) => group.attribution?.kind === 'no_permission').length
|
||||
})
|
||||
|
||||
const notAllowedCount = computed(() => {
|
||||
const groups = attributionData.value ?? []
|
||||
return groups.filter(isNotAllowed).length
|
||||
})
|
||||
|
||||
const badProofCount = computed(() => {
|
||||
const groups = attributionData.value ?? []
|
||||
return groups.filter(isBadProof).length
|
||||
})
|
||||
|
||||
const pendingCount = computed(() => {
|
||||
const groups = attributionData.value ?? []
|
||||
return groups.filter((group) => !group.attribution || isModerationRejected(group)).length
|
||||
})
|
||||
|
||||
const projectIsApproved = computed(() => project.value.status === 'approved')
|
||||
|
||||
const sortTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'Oldest', label: 'Oldest' },
|
||||
{ value: 'Newest', label: 'Newest' },
|
||||
]
|
||||
const messages = defineMessages({
|
||||
searchPlaceholder: {
|
||||
id: 'project.settings.permissions.search-placeholder',
|
||||
defaultMessage:
|
||||
'Search {count} {count, plural, one {external project} other {external projects}}...',
|
||||
defaultMessage: 'Search {count} {count, plural, one {project} other {projects}}...',
|
||||
},
|
||||
infoBannerTitle: {
|
||||
id: 'project.settings.permissions.info-banner.title',
|
||||
defaultMessage: 'Learn how attributions work',
|
||||
defaultMessage: 'Learn about distribution permissions',
|
||||
},
|
||||
infoBannerDescription: {
|
||||
id: 'project.settings.permissions.info-banner.description',
|
||||
defaultMessage: `If you include content that isn’t hosted on Modrinth, you need to let us know where it’s from and verify that you have permission to distribute the files. Check out <link>our guide</link> to learn about how to do this properly!`,
|
||||
defaultMessage: `If you include content that isn’t hosted on Modrinth, you need to let us know where it’s from and verify that you have permission to distribute the files. Check out <link>our guide</link> to learn more and get started!`,
|
||||
},
|
||||
learnMore: {
|
||||
id: 'project.settings.permissions.learn-more',
|
||||
@@ -57,15 +309,16 @@ const messages = defineMessages({
|
||||
},
|
||||
emptyStateDescription: {
|
||||
id: 'project.settings.permissions.empty-state.description',
|
||||
defaultMessage: `None of your versions contain external content, so you don't need to worry about obtaining permissions.`,
|
||||
defaultMessage: `None of your project's versions contain external content, so you don't need to worry about obtaining permissions.`,
|
||||
},
|
||||
completedTitle: {
|
||||
id: 'project.settings.permissions.completed.title',
|
||||
defaultMessage: `Attributions completed!`,
|
||||
defaultMessage: `Permissions completed!`,
|
||||
},
|
||||
completedDescription: {
|
||||
id: 'project.settings.permissions.completed.description',
|
||||
defaultMessage: 'All external content has attributions provided.',
|
||||
defaultMessage:
|
||||
'All external content has permission information and attributions have been provided.',
|
||||
},
|
||||
failTitle: {
|
||||
id: 'project.settings.permissions.fail.title',
|
||||
@@ -73,107 +326,278 @@ const messages = defineMessages({
|
||||
},
|
||||
failDescription: {
|
||||
id: 'project.settings.permissions.fail.description',
|
||||
defaultMessage: `You don't have permission to redistribute some of the external content you've added. In order to publish on Modrinth, remove the infringing content.`,
|
||||
defaultMessage: `You may not have permission to redistribute some of the external content in your project. In order to publish on Modrinth, please remove this content or provide proof that you do have permission to use it.`,
|
||||
},
|
||||
notAllowedDescription: {
|
||||
id: 'project.settings.permissions.not-allowed.description',
|
||||
defaultMessage: `Some of the external content included cannot be distributed on Modrinth because it violates our Content Rules and must be removed.`,
|
||||
},
|
||||
badProofTitle: {
|
||||
id: 'project.settings.permissions.bad-proof.title',
|
||||
defaultMessage: `Some proofs were rejected`,
|
||||
},
|
||||
badProofDescription: {
|
||||
id: 'project.settings.permissions.bad-proof.description',
|
||||
defaultMessage: `Modrinth's moderation team has rejected the permission information you provided for some external content. Please review the rejected items below and provide acceptable proof or remove the content.`,
|
||||
},
|
||||
attentionNeededTitle: {
|
||||
id: 'project.settings.permissions.attention-needed.title',
|
||||
defaultMessage: `Unknown embedded content`,
|
||||
defaultMessage: `Unknown external content`,
|
||||
},
|
||||
attentionNeededDescriptionApproved: {
|
||||
id: 'project.settings.permissions.attention-needed.description.proj-approved',
|
||||
defaultMessage: `Please provide proof that you have permission to redistribute all of the following files and any withheld versions will be automatically published.`,
|
||||
defaultMessage: `Please provide proof that you have permission to redistribute all of the following files. Once completed, withheld versions will be automatically published.`,
|
||||
},
|
||||
attentionNeededDescriptionDraft: {
|
||||
id: 'project.settings.permissions.attention-needed.description.proj-draft',
|
||||
defaultMessage: `Please provide proof that you have permission to redistribute all of the following files before you can submit your project for review.`,
|
||||
defaultMessage: `Please provide proof that you have permission to redistribute all of the following files before submitting your project for review.`,
|
||||
},
|
||||
noResults: {
|
||||
id: 'project.settings.permissions.no-results',
|
||||
defaultMessage: 'No external files match your search.',
|
||||
},
|
||||
collapseAll: {
|
||||
id: 'project.settings.permissions.collapse-all',
|
||||
defaultMessage: 'Collapse all',
|
||||
},
|
||||
expandAll: {
|
||||
id: 'project.settings.permissions.expand-all',
|
||||
defaultMessage: 'Expand all',
|
||||
},
|
||||
pendingApprovalCount: {
|
||||
id: 'project.settings.permissions.pending-approval-count',
|
||||
defaultMessage:
|
||||
'{count, plural, =0 {No attributions need approval} one {# attribution needs approval} other {# attributions need approval}}',
|
||||
},
|
||||
})
|
||||
|
||||
function defaultCardCollapsed(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
|
||||
if (group?.attribution?.kind === 'globally_allowed') {
|
||||
return true
|
||||
}
|
||||
if (!isModerator.value) {
|
||||
const hasAttribution = !!group.attribution
|
||||
const rejectedProof = group.attribution?.moderation_status?.kind === 'bad_proof'
|
||||
return hasAttribution && !rejectedProof
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function getCardCollapsed(group: Labrinth.Attribution.Internal.AttributionGroup): boolean {
|
||||
return cardCollapsedById.value[group.id] ?? defaultCardCollapsed(group)
|
||||
}
|
||||
|
||||
function setCardCollapsed(groupId: string, collapsed: boolean) {
|
||||
cardCollapsedById.value = { ...cardCollapsedById.value, [groupId]: collapsed }
|
||||
}
|
||||
|
||||
const allCardsCollapsed = computed(() => {
|
||||
const groups = filteredGroups.value
|
||||
if (groups.length === 0) {
|
||||
return true
|
||||
}
|
||||
return groups.every((group) => getCardCollapsed(group))
|
||||
})
|
||||
|
||||
const expandCollapseAllLabel = computed(() =>
|
||||
formatMessage(allCardsCollapsed.value ? messages.expandAll : messages.collapseAll),
|
||||
)
|
||||
|
||||
function toggleAllCardsCollapsed() {
|
||||
const collapsed = !allCardsCollapsed.value
|
||||
const next = { ...cardCollapsedById.value }
|
||||
for (const group of filteredGroups.value) {
|
||||
next[group.id] = collapsed
|
||||
}
|
||||
cardCollapsedById.value = next
|
||||
}
|
||||
|
||||
function dismissInfoBanner() {
|
||||
flags.value.dismissedExternalProjectsInfo = true
|
||||
saveFeatureFlags()
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<template v-if="externalFiles.length > 0">
|
||||
<Admonition
|
||||
v-if="!flags.dismissedExternalProjectsInfo"
|
||||
type="info"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.infoBannerTitle)"
|
||||
dismissible
|
||||
@dismiss="dismissInfoBanner"
|
||||
>
|
||||
<IntlFormatted :message-id="messages.infoBannerDescription">
|
||||
<template #link="{ children }">
|
||||
<a class="text-link" target="_blank"> <component :is="() => children" /> </a>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
<template #actions>
|
||||
<div class="flex">
|
||||
<ButtonStyled color="blue">
|
||||
<a> {{ formatMessage(messages.learnMore) }} <RightArrowIcon /> </a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<Admonition
|
||||
v-if="!flags.dismissedExternalProjectsInfo && !isModerator"
|
||||
type="info"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.infoBannerTitle)"
|
||||
dismissible
|
||||
@dismiss="dismissInfoBanner"
|
||||
>
|
||||
<IntlFormatted :message-id="messages.infoBannerDescription">
|
||||
<template #link="{ children }">
|
||||
<a class="text-link" target="_blank"> <component :is="() => children" /> </a>
|
||||
</template>
|
||||
</Admonition>
|
||||
<Admonition
|
||||
v-if="true"
|
||||
type="success"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.completedTitle)"
|
||||
:body="formatMessage(messages.completedDescription)"
|
||||
/>
|
||||
<Admonition
|
||||
v-if="true"
|
||||
type="warning"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.attentionNeededTitle)"
|
||||
:body="formatMessage(messages.attentionNeededDescriptionDraft)"
|
||||
/>
|
||||
<Admonition
|
||||
v-if="true"
|
||||
type="critical"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.failTitle)"
|
||||
:body="formatMessage(messages.failDescription)"
|
||||
/>
|
||||
<div class="grid grid-cols-[1fr_auto] gap-2">
|
||||
<StyledInput
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="
|
||||
formatMessage(messages.searchPlaceholder, {
|
||||
count: externalFiles.length,
|
||||
})
|
||||
"
|
||||
:icon="SearchIcon"
|
||||
input-class="h-[40px]"
|
||||
/>
|
||||
<div>
|
||||
<Combobox
|
||||
v-model="currentSortType"
|
||||
class="!w-full flex-grow sm:!w-[150px] sm:flex-grow-0 lg:!w-[150px]"
|
||||
:options="sortTypes"
|
||||
:placeholder="formatMessage(commonMessages.sortByLabel)"
|
||||
>
|
||||
<template #selected>
|
||||
<span class="flex flex-row gap-2 align-middle font-semibold">
|
||||
<SortAscIcon
|
||||
v-if="currentSortType === 'Oldest'"
|
||||
class="size-5 flex-shrink-0 text-secondary"
|
||||
/>
|
||||
<SortDescIcon v-else class="size-5 flex-shrink-0 text-secondary" />
|
||||
<span class="truncate text-contrast">{{ currentSortType }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
</IntlFormatted>
|
||||
<template #actions>
|
||||
<div class="flex">
|
||||
<ButtonStyled color="blue">
|
||||
<a> {{ formatMessage(messages.learnMore) }} <RightArrowIcon /> </a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-col gap-3">
|
||||
<ExternalProjectPermissionsCard title="FTB Library" />
|
||||
</template>
|
||||
</Admonition>
|
||||
<template v-if="pending">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
class="h-[56px] w-full animate-pulse rounded-2xl bg-surface-3"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="totalGroups > 0">
|
||||
<template v-if="!isModerator">
|
||||
<Admonition
|
||||
v-if="
|
||||
stats.pending === 0 &&
|
||||
stats.noPermission === 0 &&
|
||||
notAllowedCount === 0 &&
|
||||
badProofCount === 0
|
||||
"
|
||||
type="success"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.completedTitle)"
|
||||
:body="formatMessage(messages.completedDescription)"
|
||||
/>
|
||||
<Admonition
|
||||
v-else-if="notAllowedCount > 0"
|
||||
type="critical"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.failTitle)"
|
||||
:body="formatMessage(messages.notAllowedDescription)"
|
||||
/>
|
||||
<Admonition
|
||||
v-else-if="stats.noPermission > 0"
|
||||
type="critical"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.failTitle)"
|
||||
:body="formatMessage(messages.failDescription)"
|
||||
/>
|
||||
<Admonition
|
||||
v-else-if="badProofCount > 0"
|
||||
type="critical"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.badProofTitle)"
|
||||
:body="formatMessage(messages.badProofDescription)"
|
||||
/>
|
||||
<Admonition
|
||||
v-else-if="stats.pending > 0"
|
||||
type="warning"
|
||||
class="mb-4"
|
||||
:header="formatMessage(messages.attentionNeededTitle)"
|
||||
:body="
|
||||
formatMessage(
|
||||
projectIsApproved
|
||||
? messages.attentionNeededDescriptionApproved
|
||||
: messages.attentionNeededDescriptionDraft,
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="grid grid-cols-[1fr_auto_auto] gap-2">
|
||||
<StyledInput
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
clearable
|
||||
:placeholder="
|
||||
formatMessage(messages.searchPlaceholder, {
|
||||
count: totalGroups,
|
||||
})
|
||||
"
|
||||
:icon="SearchIcon"
|
||||
input-class="h-[40px]"
|
||||
/>
|
||||
<div>
|
||||
<Combobox
|
||||
v-model="currentSortType"
|
||||
class="!w-full flex-grow sm:!w-[220px] sm:flex-grow-0 [&>span]:h-[40px]"
|
||||
:options="sortTypes"
|
||||
:placeholder="formatMessage(commonMessages.sortByLabel)"
|
||||
>
|
||||
<template #selected>
|
||||
<span class="flex flex-row gap-2 align-middle font-semibold">
|
||||
<ArrowDownWideNarrowIcon
|
||||
v-if="currentSortType === 'status'"
|
||||
class="size-5 flex-shrink-0 text-secondary"
|
||||
/>
|
||||
<ArrowDown10Icon
|
||||
v-else-if="currentSortType === 'most_files'"
|
||||
class="size-5 flex-shrink-0 text-secondary"
|
||||
/>
|
||||
<ClockArrowDownIcon
|
||||
v-else-if="currentSortType === 'recently_edited'"
|
||||
class="size-5 flex-shrink-0 text-secondary"
|
||||
/>
|
||||
<ArrowDownWideNarrowIcon
|
||||
v-else-if="currentSortType === 'rejected'"
|
||||
class="size-5 flex-shrink-0 text-secondary"
|
||||
/>
|
||||
<span class="truncate text-contrast">{{ currentSortLabel }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
</div>
|
||||
<ButtonStyled>
|
||||
<button type="button" class="!h-[40px]" @click="toggleAllCardsCollapsed">
|
||||
<UnfoldVerticalIcon
|
||||
v-if="allCardsCollapsed"
|
||||
class="size-5 flex-shrink-0 text-secondary"
|
||||
/>
|
||||
<FoldVerticalIcon v-else class="size-5 flex-shrink-0 text-secondary" />
|
||||
{{ expandCollapseAllLabel }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isModerator" class="mt-4 flex flex-wrap items-center gap-3">
|
||||
<template v-if="noPermissionCount > 0">
|
||||
<span class="flex items-center gap-1 text-red">
|
||||
<XCircleIcon class="size-4 shrink-0" /> {{ noPermissionCount }} with no permission
|
||||
</span>
|
||||
<span class="text-surface-5">•</span>
|
||||
</template>
|
||||
<template v-if="pendingCount > 0">
|
||||
<span class="flex items-center gap-1 text-orange">
|
||||
<PauseIcon class="size-4 shrink-0" /> {{ pendingCount }} need user action
|
||||
</span>
|
||||
<span class="text-surface-5">•</span>
|
||||
</template>
|
||||
<span v-if="pendingApprovalCount > 0" class="flex items-center gap-1 text-contrast">
|
||||
<ScaleIcon class="size-4 shrink-0" /> {{ pendingApprovalCount }} awaiting review
|
||||
</span>
|
||||
<span v-else class="flex items-center gap-1 text-secondary">
|
||||
<ScaleIcon class="size-4 shrink-0" /> None awaiting review
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-col gap-3">
|
||||
<TransitionGroup name="list">
|
||||
<ExternalProjectPermissionsCard
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.id"
|
||||
:collapsed="getCardCollapsed(group)"
|
||||
:project-id="project.id"
|
||||
:group="group"
|
||||
:is-moderator="isModerator"
|
||||
@update:collapsed="setCardCollapsed(group.id, $event)"
|
||||
/>
|
||||
<EmptyState
|
||||
v-if="filteredGroups.length === 0"
|
||||
:heading="formatMessage(messages.noResults)"
|
||||
type="no-search-result"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="attributionError">
|
||||
<p v-if="attributionError" class="my-12 text-center text-red">
|
||||
{{ attributionError }}
|
||||
</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<EmptyState
|
||||
:heading="formatMessage(messages.emptyStateHeading)"
|
||||
@@ -182,3 +606,18 @@ function dismissInfoBanner() {
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<style scoped>
|
||||
.list-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.list-move {
|
||||
transition: transform 200ms ease-in-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,21 +14,22 @@
|
||||
proceed-label="Delete"
|
||||
@proceed="deleteVersion()"
|
||||
/>
|
||||
|
||||
<Admonition
|
||||
v-if="flags.modpackPermissionsPage && withheldVersions.length > 0"
|
||||
type="warning"
|
||||
v-if="withheldVersions.length > 0"
|
||||
type="circle-warning"
|
||||
class="mb-4"
|
||||
:header="
|
||||
formatMessage(messages.withheldVersionsWarningTitle, {
|
||||
count: withheldVersions.length,
|
||||
version_name: withheldVersions.length === 1 ? withheldVersions[0] : undefined,
|
||||
version_name:
|
||||
withheldVersions.length === 1 ? withheldVersions[0].version_number : undefined,
|
||||
})
|
||||
"
|
||||
:body="
|
||||
formatMessage(messages.withheldVersionsWarningDescription, {
|
||||
count: withheldVersions.length,
|
||||
version_name: withheldVersions.length === 1 ? withheldVersions[0] : undefined,
|
||||
version_name:
|
||||
withheldVersions.length === 1 ? withheldVersions[0].version_number : undefined,
|
||||
})
|
||||
"
|
||||
>
|
||||
@@ -40,7 +41,8 @@
|
||||
project.slug ? project.slug : project.id
|
||||
}/settings/permissions`"
|
||||
>
|
||||
{{ formatMessage(messages.withheldVersionsWarningResolve) }} <RightArrowIcon />
|
||||
{{ formatMessage(commonProjectSettingsMessages.withheldVersionsWarningResolve) }}
|
||||
<RightArrowIcon />
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@@ -333,6 +335,7 @@ import {
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
commonProjectSettingsMessages,
|
||||
ConfirmModal,
|
||||
defineMessages,
|
||||
injectModrinthClient,
|
||||
@@ -345,7 +348,7 @@ import {
|
||||
import { useTemplateRef, watch } from 'vue'
|
||||
|
||||
import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue'
|
||||
import { getSignInRouteObj } from '~/composables/auth.js'
|
||||
import { getSignInRouteObj } from '~/composables/auth.ts'
|
||||
import { reportVersion } from '~/utils/report-helpers.ts'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -458,7 +461,9 @@ async function deleteVersion() {
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
const withheldVersions = computed(() => ['4.0.0'])
|
||||
const withheldVersions = computed(() =>
|
||||
versions.value.filter((x) => x.files_missing_attribution?.length > 0),
|
||||
)
|
||||
|
||||
const messages = defineMessages({
|
||||
withheldVersionsWarningTitle: {
|
||||
@@ -471,9 +476,5 @@ const messages = defineMessages({
|
||||
defaultMessage:
|
||||
'{count, plural, one {This version is} other {These versions are}} currently withheld and not publicly listed. Please provide proof that you have permission to redistribute certain files included in the modpack {count, plural, one {version} other {versions}}.',
|
||||
},
|
||||
withheldVersionsWarningResolve: {
|
||||
id: 'project.versions.withheld-versions-warning.resolve-button',
|
||||
defaultMessage: 'Resolve',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -269,7 +269,7 @@ import {
|
||||
import { onMounted, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue'
|
||||
import { getSignInRouteObj } from '~/composables/auth.js'
|
||||
import { getSignInRouteObj } from '~/composables/auth.ts'
|
||||
import { reportVersion } from '~/utils/report-helpers.ts'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
@@ -8,7 +8,9 @@ useSeoMeta({
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<NuxtPage class="auth-container universal-card" />
|
||||
<div class="grid min-h-[calc(100vh-4.5rem)] place-items-center pb-20">
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@@ -31,19 +33,17 @@ useSeoMeta({
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-container .btn-wrapper :is(a, button) {
|
||||
.auth-container .btn {
|
||||
font-weight: 700;
|
||||
min-height: 2.5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.centered-btn :is(a, button),
|
||||
.centered-btn:is(a, button) {
|
||||
.centered-btn {
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.continue-btn :is(a, button) svg,
|
||||
.continue-btn:is(a, button) svg {
|
||||
.btn.continue-btn svg {
|
||||
margin: 0 0 0 0.5rem;
|
||||
}
|
||||
|
||||
@@ -54,19 +54,19 @@ useSeoMeta({
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.third-party a {
|
||||
.third-party .btn {
|
||||
width: 100%;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.third-party a svg {
|
||||
.third-party .btn svg {
|
||||
margin-right: var(--gap-sm);
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 25.5rem) {
|
||||
.third-party a {
|
||||
.third-party .btn {
|
||||
grid-column: 1 / 3;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,33 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="error" class="oauth-items">
|
||||
<div>
|
||||
<h1>{{ formatMessage(commonMessages.errorLabel) }}</h1>
|
||||
</div>
|
||||
<p>
|
||||
<span>{{ error.data?.error }}: </span>
|
||||
{{ error.data?.description }}
|
||||
<div
|
||||
class="universal-card flex w-full max-w-[28rem] flex-col gap-6 border border-solid border-surface-5"
|
||||
>
|
||||
<div v-if="authorizationError" class="flex flex-col gap-2.5">
|
||||
<h1 class="m-0 mx-auto text-xl font-semibold text-contrast">
|
||||
{{ formatMessage(messages.errorTitle) }}
|
||||
</h1>
|
||||
<p class="m-0">
|
||||
<span>{{ authorizationError.error }}: </span>
|
||||
{{ authorizationError.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="app && createdBy && authorizationData" class="oauth-items">
|
||||
<div class="connected-items">
|
||||
<div class="profile-pics">
|
||||
<div v-else-if="app && createdBy && authorizationData" class="flex flex-col gap-6">
|
||||
<div class="mt-4 flex items-center justify-center">
|
||||
<div class="flex w-full flex-row items-center justify-evenly">
|
||||
<Avatar size="md" :src="app.icon_url" />
|
||||
<!-- <img class="profile-pic" :src="app.icon_url" alt="User profile picture" /> -->
|
||||
<div class="connection-indicator">→</div>
|
||||
<Avatar size="md" circle :src="auth.user.avatar_url" />
|
||||
<div class="flex select-none items-center justify-center text-[2rem] text-primary">→</div>
|
||||
<Avatar size="md" circle :src="auth.user?.avatar_url" />
|
||||
<!-- <img class="profile-pic" :src="auth.user.avatar_url" alt="User profile picture" /> -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="title">
|
||||
<h1>{{ formatMessage(messages.title, { appName: app.name }) }}</h1>
|
||||
<div class="mx-auto">
|
||||
<h1 class="mb-0 ml-0 mr-0 mt-0 text-xl text-contrast">
|
||||
{{ formatMessage(messages.title, { appName: app.name }) }}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="auth-info">
|
||||
<div class="scope-heading">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="mb-3">
|
||||
<IntlFormatted
|
||||
:message-id="messages.appInfo"
|
||||
:values="{
|
||||
@@ -43,11 +47,11 @@
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</div>
|
||||
<div class="scope-items">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div v-for="scopeItem in scopeDefinitions" :key="scopeItem">
|
||||
<div class="scope-item">
|
||||
<div class="scope-icon">
|
||||
<CheckIcon />
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div class="grid h-5 min-h-5 w-5 min-w-5 place-content-center rounded-full bg-green">
|
||||
<CheckIcon class="text-sm text-black" />
|
||||
</div>
|
||||
{{ scopeItem }}
|
||||
</div>
|
||||
@@ -68,11 +72,11 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="redirection-notice">
|
||||
<p class="redirect-instructions">
|
||||
<div class="flex flex-col gap-2 text-center">
|
||||
<p class="m-0 text-sm">
|
||||
<IntlFormatted :message-id="messages.redirectUrl" :values="{ url: redirectUri }">
|
||||
<template #redirect-url="{ children }">
|
||||
<span class="redirect-url">
|
||||
<span class="font-bold">
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
</span>
|
||||
</template>
|
||||
@@ -83,7 +87,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
@@ -98,10 +102,35 @@ import {
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed } from 'vue'
|
||||
import type { LocationQueryValue } from 'vue-router'
|
||||
|
||||
import { useAuth } from '@/composables/auth.js'
|
||||
import { useScopes } from '@/composables/auth/scopes.ts'
|
||||
|
||||
interface ApiErrorShape {
|
||||
data?: {
|
||||
description?: string
|
||||
error?: string
|
||||
}
|
||||
}
|
||||
|
||||
const getQueryString = (
|
||||
value: LocationQueryValue | LocationQueryValue[] | null | undefined,
|
||||
): string => {
|
||||
const firstValue = Array.isArray(value) ? value[0] : value
|
||||
return typeof firstValue === 'string' ? firstValue : ''
|
||||
}
|
||||
|
||||
const getErrorMessage = (error: unknown): string => {
|
||||
const apiError = error as ApiErrorShape
|
||||
if (typeof apiError?.data?.description === 'string') {
|
||||
return apiError.data.description
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -124,6 +153,10 @@ const messages = defineMessages({
|
||||
id: 'auth.authorize.error.no-redirect-url',
|
||||
defaultMessage: 'No redirect location found in response',
|
||||
},
|
||||
missingParametersError: {
|
||||
id: 'auth.authorize.error.missing-parameters',
|
||||
defaultMessage: 'Missing required OAuth query parameters.',
|
||||
},
|
||||
redirectUrl: {
|
||||
id: 'auth.authorize.redirect-url',
|
||||
defaultMessage: 'You will be redirected to <redirect-url>{url}</redirect-url>',
|
||||
@@ -132,33 +165,35 @@ const messages = defineMessages({
|
||||
id: 'auth.authorize.authorize-app-name',
|
||||
defaultMessage: 'Authorize {appName}',
|
||||
},
|
||||
errorTitle: {
|
||||
id: 'auth.authorize.errro-title',
|
||||
defaultMessage: 'An Error Occured',
|
||||
},
|
||||
})
|
||||
|
||||
const router = useNativeRoute()
|
||||
const auth = await useAuth()
|
||||
const { scopesToDefinitions } = useScopes()
|
||||
|
||||
const clientId = router.query?.client_id || false
|
||||
const redirectUri = router.query?.redirect_uri || false
|
||||
const scope = router.query?.scope || false
|
||||
const state = router.query?.state || false
|
||||
const clientId = computed(() => getQueryString(router.query.client_id))
|
||||
const redirectUri = computed(() => getQueryString(router.query.redirect_uri))
|
||||
const scope = computed(() => getQueryString(router.query.scope))
|
||||
const state = computed(() => getQueryString(router.query.state))
|
||||
const hasRequiredParams = computed(() => !!clientId.value && !!redirectUri.value && !!scope.value)
|
||||
|
||||
const getFlowIdAuthorization = async () => {
|
||||
const params = {
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope,
|
||||
}
|
||||
if (state) {
|
||||
params.state = state
|
||||
}
|
||||
|
||||
const authorization = await client.labrinth.oauth_internal.authorize(params)
|
||||
const authorization = await client.labrinth.oauth_internal.authorize({
|
||||
client_id: clientId.value,
|
||||
redirect_uri: redirectUri.value,
|
||||
scope: scope.value,
|
||||
...(state.value ? { state: state.value } : {}),
|
||||
})
|
||||
|
||||
if (typeof authorization === 'string') {
|
||||
await navigateTo(authorization, {
|
||||
external: true,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
return authorization
|
||||
@@ -170,27 +205,60 @@ const {
|
||||
error,
|
||||
suspense: authSusp,
|
||||
} = useQuery({
|
||||
queryKey: computed(() => ['authorization', clientId, redirectUri, scope, state]),
|
||||
queryKey: computed(() => [
|
||||
'authorization',
|
||||
clientId.value,
|
||||
redirectUri.value,
|
||||
scope.value,
|
||||
state.value,
|
||||
]),
|
||||
queryFn: getFlowIdAuthorization,
|
||||
enabled: computed(() => !!clientId && !!redirectUri && !!scope),
|
||||
enabled: hasRequiredParams,
|
||||
})
|
||||
|
||||
const authorizationError = computed(() => {
|
||||
if (!hasRequiredParams.value) {
|
||||
return {
|
||||
error: 'invalid_request',
|
||||
description: formatMessage(messages.missingParametersError),
|
||||
}
|
||||
}
|
||||
|
||||
const apiError = (error.value as ApiErrorShape | null)?.data
|
||||
if (apiError) {
|
||||
return apiError
|
||||
}
|
||||
|
||||
if (error.value) {
|
||||
return {
|
||||
error: 'server_error',
|
||||
description: getErrorMessage(error.value),
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const { data: app, suspense: appSusp } = useQuery({
|
||||
queryKey: computed(() => ['oauth/app', clientId]),
|
||||
queryFn: () => client.labrinth.oauth_internal.getApp(clientId),
|
||||
enabled: computed(() => !!clientId),
|
||||
queryKey: computed(() => ['oauth/app', clientId.value]),
|
||||
queryFn: () => client.labrinth.oauth_internal.getApp(clientId.value),
|
||||
enabled: computed(() => !!clientId.value),
|
||||
})
|
||||
|
||||
const { data: createdBy, suspense: userSusp } = useQuery({
|
||||
queryKey: computed(() => ['user', app.value?.created_by]),
|
||||
queryFn: () => client.labrinth.users_v2.get(app.value.created_by),
|
||||
queryFn: () => {
|
||||
if (!app.value?.created_by) {
|
||||
throw new Error('Missing OAuth app creator')
|
||||
}
|
||||
|
||||
return client.labrinth.users_v2.get(app.value.created_by)
|
||||
},
|
||||
enabled: computed(() => !!app.value?.created_by),
|
||||
})
|
||||
|
||||
onServerPrefetch(async () => {
|
||||
await authSusp()
|
||||
await appSusp()
|
||||
await userSusp()
|
||||
await Promise.allSettled([authSusp(), appSusp(), userSusp()])
|
||||
})
|
||||
|
||||
const scopeDefinitions = computed(() =>
|
||||
@@ -199,6 +267,10 @@ const scopeDefinitions = computed(() =>
|
||||
|
||||
const onAuthorize = async () => {
|
||||
try {
|
||||
if (!authorizationData.value?.flow_id) {
|
||||
throw new Error(formatMessage(messages.noRedirectUrlError))
|
||||
}
|
||||
|
||||
const res = await client.labrinth.oauth_internal.accept({
|
||||
flow: authorizationData.value.flow_id,
|
||||
})
|
||||
@@ -214,7 +286,7 @@ const onAuthorize = async () => {
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data ? err.data.description : err,
|
||||
text: getErrorMessage(err),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
@@ -222,6 +294,10 @@ const onAuthorize = async () => {
|
||||
|
||||
const onReject = async () => {
|
||||
try {
|
||||
if (!authorizationData.value?.flow_id) {
|
||||
throw new Error(formatMessage(messages.noRedirectUrlError))
|
||||
}
|
||||
|
||||
const res = await client.labrinth.oauth_internal.reject({
|
||||
flow: authorizationData.value.flow_id,
|
||||
})
|
||||
@@ -237,7 +313,7 @@ const onReject = async () => {
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data ? err.data.description : err,
|
||||
text: getErrorMessage(err),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
@@ -247,130 +323,3 @@ definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.oauth-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xl);
|
||||
}
|
||||
|
||||
.scope-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-sm);
|
||||
}
|
||||
|
||||
.scope-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-sm);
|
||||
}
|
||||
|
||||
.scope-icon {
|
||||
display: flex;
|
||||
|
||||
color: var(--color-raised-bg);
|
||||
background-color: var(--color-green);
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
padding: var(--gap-xs);
|
||||
}
|
||||
.title {
|
||||
margin-inline: auto;
|
||||
|
||||
h1 {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
.redirection-notice {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xs);
|
||||
text-align: center;
|
||||
|
||||
.redirect-instructions {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.redirect-url {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.wide-button {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--gap-xs);
|
||||
justify-content: center;
|
||||
}
|
||||
.auth-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-sm);
|
||||
}
|
||||
|
||||
.scope-heading {
|
||||
margin-bottom: var(--gap-sm);
|
||||
}
|
||||
|
||||
.profile-pics {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-evenly;
|
||||
|
||||
.connection-indicator {
|
||||
// Make sure the text sits in the middle and is centered.
|
||||
// Make the text large, and make sure it's not selectable.
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
user-select: none;
|
||||
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.profile-pic {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
border-radius: 50%;
|
||||
margin: 0 1rem;
|
||||
}
|
||||
|
||||
.dotted-border-line {
|
||||
width: 75%;
|
||||
border: 0.1rem dashed var(--color-divider);
|
||||
}
|
||||
|
||||
.connected-items {
|
||||
// Display dotted-border-line under profile-pics and centered behind them
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 1rem;
|
||||
|
||||
// Display profile-pics on top of dotted-border-line
|
||||
.profile-pics {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
// Display dotted-border-line behind profile-pics
|
||||
.dotted-border-line {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<div v-if="subtleLauncherRedirectUri">
|
||||
<iframe
|
||||
:src="subtleLauncherRedirectUri"
|
||||
class="fixed left-0 top-0 z-[9999] m-0 h-full w-full border-0 p-0"
|
||||
></iframe>
|
||||
</div>
|
||||
<CreateAccountView
|
||||
v-else
|
||||
v-model:date-of-birth="dateOfBirth"
|
||||
v-model:username="username"
|
||||
v-model:token="token"
|
||||
v-model:subscribe="subscribe"
|
||||
:globals="globals"
|
||||
:requires-dob="requiresDob"
|
||||
:on-complete-sign-up="completeOAuthSignUp"
|
||||
:on-set-captcha-ref="setCaptchaRef"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import type { LocationQueryValue } from 'vue-router'
|
||||
|
||||
import CreateAccountView from '@/components/ui/auth/CreateAccount.vue'
|
||||
import { getLauncherRedirectUrl, promotePendingSignInOAuthProvider } from '@/composables/auth.ts'
|
||||
|
||||
interface AuthGlobalsResponse {
|
||||
captcha_enabled?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface ApiErrorShape {
|
||||
data?: {
|
||||
description?: string
|
||||
}
|
||||
}
|
||||
|
||||
const getQueryString = (
|
||||
value: LocationQueryValue | LocationQueryValue[] | null | undefined,
|
||||
): string => {
|
||||
const firstValue = Array.isArray(value) ? value[0] : value
|
||||
return typeof firstValue === 'string' ? firstValue : ''
|
||||
}
|
||||
|
||||
const getErrorMessage = (error: unknown): string => {
|
||||
const apiError = error as ApiErrorShape
|
||||
if (typeof apiError?.data?.description === 'string') {
|
||||
return apiError.data.description
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const route = useNativeRoute()
|
||||
const auth = await useAuth()
|
||||
|
||||
const messages = defineMessages({
|
||||
createAccountTitle: {
|
||||
id: 'auth.create-account.page-title',
|
||||
defaultMessage: 'Create Account',
|
||||
},
|
||||
})
|
||||
|
||||
useHead({
|
||||
title() {
|
||||
return `${formatMessage(messages.createAccountTitle)} - Modrinth`
|
||||
},
|
||||
})
|
||||
|
||||
const requiresDob = computed(() => {
|
||||
const raw = route.query.requires_dob
|
||||
const value = Array.isArray(raw) ? raw[0] : raw
|
||||
|
||||
if (!value) {
|
||||
return false
|
||||
}
|
||||
|
||||
return value === 'true' || value === '1'
|
||||
})
|
||||
|
||||
const oauthFlowState = computed(() => {
|
||||
const state = route.query.state
|
||||
const value = Array.isArray(state) ? state[0] : state
|
||||
return typeof value === 'string' ? value : ''
|
||||
})
|
||||
|
||||
const defaultUsername = computed(() => {
|
||||
const queryUsername = route.query.username
|
||||
const value = Array.isArray(queryUsername) ? queryUsername[0] : queryUsername
|
||||
return typeof value === 'string' && value.length > 0 ? value : ''
|
||||
})
|
||||
|
||||
const dateOfBirth = ref('')
|
||||
const username = ref(defaultUsername.value)
|
||||
const token = ref('')
|
||||
const subscribe = ref(false)
|
||||
const subtleLauncherRedirectUri = ref<string>()
|
||||
|
||||
const captcha = ref<{ reset?: () => void } | null>(null)
|
||||
const setCaptchaRef = (captchaRef: unknown) => {
|
||||
captcha.value = (captchaRef as { reset?: () => void } | null) ?? null
|
||||
}
|
||||
|
||||
const { data: globals } = useQuery<AuthGlobalsResponse>({
|
||||
queryKey: ['auth-globals'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await client.labrinth.globals_internal.get()
|
||||
} catch (err) {
|
||||
console.error('Error fetching globals:', err)
|
||||
return { captcha_enabled: true, tax_compliance_thresholds: {} }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
async function completeOAuthSignUp() {
|
||||
startLoading()
|
||||
try {
|
||||
if (!oauthFlowState.value) {
|
||||
throw new Error('Missing OAuth flow state')
|
||||
}
|
||||
|
||||
const res = await client.labrinth.auth_v2.createOAuthAccount({
|
||||
username: username.value.trim() || defaultUsername.value,
|
||||
state: oauthFlowState.value,
|
||||
challenge: token.value,
|
||||
sign_up_newsletter: subscribe.value,
|
||||
})
|
||||
|
||||
await finishSignIn(res.session)
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: getErrorMessage(err),
|
||||
type: 'error',
|
||||
})
|
||||
captcha.value?.reset?.()
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
async function finishSignIn(sessionToken?: string | null) {
|
||||
if (route.query.launcher) {
|
||||
let token = sessionToken
|
||||
if (!token) {
|
||||
token = auth.value.token
|
||||
}
|
||||
|
||||
promotePendingSignInOAuthProvider()
|
||||
|
||||
const redirectUrl = `${getLauncherRedirectUrl(route)}/?code=${token}`
|
||||
|
||||
if (redirectUrl.startsWith('https://launcher-files.modrinth.com/')) {
|
||||
await navigateTo(redirectUrl, {
|
||||
external: true,
|
||||
})
|
||||
} else {
|
||||
subtleLauncherRedirectUri.value = redirectUrl
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (sessionToken) {
|
||||
await useAuth(sessionToken)
|
||||
await useUser()
|
||||
queryClient.clear()
|
||||
|
||||
promotePendingSignInOAuthProvider()
|
||||
}
|
||||
|
||||
if (route.query.redirect) {
|
||||
const redirect = decodeURIComponent(getQueryString(route.query.redirect))
|
||||
await navigateTo(redirect, {
|
||||
replace: true,
|
||||
})
|
||||
} else {
|
||||
await navigateTo('/dashboard')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,14 +1,24 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>{{ formatMessage(messages.longTitle) }}</h1>
|
||||
<section class="auth-form">
|
||||
<template v-if="step === 'choose_method'">
|
||||
<p>
|
||||
{{ formatMessage(methodChoiceMessages.description) }}
|
||||
</p>
|
||||
<div
|
||||
class="universal-card flex w-full max-w-[28rem] flex-col gap-6 border border-solid border-surface-5"
|
||||
>
|
||||
<h1 class="m-0 mx-auto text-xl font-semibold text-contrast">
|
||||
{{ formatMessage(messages.longTitle) }}
|
||||
</h1>
|
||||
<template v-if="step === 'choose_method'">
|
||||
<Admonition :type="'info'">
|
||||
<template #header>
|
||||
<div class="-mb-2 flex flex-col gap-1.5 font-normal leading-normal">
|
||||
<div>
|
||||
{{ formatMessage(methodChoiceMessages.description) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Admonition>
|
||||
|
||||
<label for="email" hidden>
|
||||
{{ formatMessage(commonMessages.emailUsernameLabel) }}
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label class="text-md font-semibold text-contrast" for="email">
|
||||
{{ formatMessage(commonMessages.emailLabel) }}
|
||||
</label>
|
||||
<StyledInput
|
||||
id="email"
|
||||
@@ -19,22 +29,37 @@
|
||||
:placeholder="formatMessage(commonMessages.emailLabel)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label class="text-md font-semibold text-contrast">{{
|
||||
formatMessage(messages.securityCheckLabel)
|
||||
}}</label>
|
||||
<HCaptcha v-if="globals?.captcha_enabled" ref="captcha" v-model="token" />
|
||||
</div>
|
||||
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
class="mx-auto"
|
||||
:disabled="globals?.captcha_enabled ? !token : false"
|
||||
@click="recovery"
|
||||
>
|
||||
<SendIcon /> {{ formatMessage(methodChoiceMessages.action) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-else-if="step === 'passed_challenge'">
|
||||
<p>{{ formatMessage(postChallengeMessages.description) }}</p>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
class="!w-full"
|
||||
:disabled="globals?.captcha_enabled ? !token : false"
|
||||
@click="recovery"
|
||||
>
|
||||
<SendIcon /> {{ formatMessage(methodChoiceMessages.action) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-else-if="step === 'passed_challenge'">
|
||||
<Admonition :type="'info'">
|
||||
<template #header>
|
||||
<div class="-mb-2 flex flex-col gap-1.5 font-normal leading-normal">
|
||||
<div>
|
||||
{{ formatMessage(postChallengeMessages.description) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Admonition>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label for="password" hidden>{{ formatMessage(commonMessages.passwordLabel) }}</label>
|
||||
<StyledInput
|
||||
id="password"
|
||||
@@ -60,17 +85,18 @@
|
||||
/>
|
||||
|
||||
<ButtonStyled color="brand">
|
||||
<button class="auth-form__input continue-btn" @click="changePassword">
|
||||
<button class="!w-full" @click="changePassword">
|
||||
{{ formatMessage(postChallengeMessages.action) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { KeyIcon, MailIcon, SendIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
@@ -80,8 +106,41 @@ import {
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import type { LocationQueryValue } from 'vue-router'
|
||||
|
||||
import HCaptcha from '@/components/ui/HCaptcha.vue'
|
||||
import HCaptcha from '@/components/ui/auth/HCaptcha.vue'
|
||||
|
||||
interface AuthGlobalsResponse {
|
||||
captcha_enabled?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface ApiErrorShape {
|
||||
data?: {
|
||||
description?: string
|
||||
error?: string
|
||||
}
|
||||
}
|
||||
|
||||
type ResetPasswordStep = 'choose_method' | 'passed_challenge'
|
||||
|
||||
const getQueryString = (
|
||||
value: LocationQueryValue | LocationQueryValue[] | null | undefined,
|
||||
): string => {
|
||||
const firstValue = Array.isArray(value) ? value[0] : value
|
||||
return typeof firstValue === 'string' ? firstValue : ''
|
||||
}
|
||||
|
||||
const getErrorMessage = (error: unknown): string => {
|
||||
const apiError = error as ApiErrorShape
|
||||
if (typeof apiError?.data?.description === 'string') {
|
||||
return apiError.data.description
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
@@ -149,6 +208,10 @@ const messages = defineMessages({
|
||||
id: 'auth.reset-password.title.long',
|
||||
defaultMessage: 'Reset your password',
|
||||
},
|
||||
securityCheckLabel: {
|
||||
id: 'auth.create-account.security-check.label',
|
||||
defaultMessage: 'Security check',
|
||||
},
|
||||
})
|
||||
|
||||
useHead({
|
||||
@@ -162,15 +225,15 @@ if (auth.value.user) {
|
||||
|
||||
const route = useNativeRoute()
|
||||
|
||||
const step = ref('choose_method')
|
||||
const step = ref<ResetPasswordStep>('choose_method')
|
||||
|
||||
if (route.query.flow) {
|
||||
step.value = 'passed_challenge'
|
||||
}
|
||||
|
||||
const captcha = ref()
|
||||
const captcha = ref<{ reset?: () => void } | null>(null)
|
||||
|
||||
const { data: globals } = useQuery({
|
||||
const { data: globals } = useQuery<AuthGlobalsResponse>({
|
||||
queryKey: ['auth-globals'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
@@ -201,10 +264,10 @@ async function recovery() {
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data ? err.data.description : err,
|
||||
text: getErrorMessage(err),
|
||||
type: 'error',
|
||||
})
|
||||
captcha.value?.reset()
|
||||
captcha.value?.reset?.()
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
@@ -217,7 +280,7 @@ async function changePassword() {
|
||||
try {
|
||||
await client.labrinth.auth_v2.changePassword({
|
||||
new_password: newPassword.value,
|
||||
flow: route.query.flow,
|
||||
flow: getQueryString(route.query.flow),
|
||||
})
|
||||
|
||||
addNotification({
|
||||
@@ -229,10 +292,10 @@ async function changePassword() {
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data ? err.data.description : err,
|
||||
text: getErrorMessage(err),
|
||||
type: 'error',
|
||||
})
|
||||
captcha.value?.reset()
|
||||
captcha.value?.reset?.()
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
@@ -1,171 +1,70 @@
|
||||
<template>
|
||||
<div v-if="subtleLauncherRedirectUri">
|
||||
<iframe
|
||||
:src="subtleLauncherRedirectUri"
|
||||
class="fixed left-0 top-0 z-[9999] m-0 h-full w-full border-0 p-0"
|
||||
></iframe>
|
||||
</div>
|
||||
<div v-else>
|
||||
<template v-if="flow && !subtleLauncherRedirectUri">
|
||||
<label for="two-factor-code">
|
||||
<span class="label__title">{{ formatMessage(messages.twoFactorCodeLabel) }}</span>
|
||||
<span class="label__description">
|
||||
{{ formatMessage(messages.twoFactorCodeLabelDescription) }}
|
||||
</span>
|
||||
</label>
|
||||
<StyledInput
|
||||
id="two-factor-code"
|
||||
v-model="twoFactorCode"
|
||||
:maxlength="11"
|
||||
inputmode="numeric"
|
||||
:placeholder="formatMessage(messages.twoFactorCodeInputPlaceholder)"
|
||||
autocomplete="one-time-code"
|
||||
@keyup.enter="begin2FASignIn"
|
||||
/>
|
||||
|
||||
<ButtonStyled color="brand">
|
||||
<button class="continue-btn" @click="begin2FASignIn">
|
||||
{{ formatMessage(commonMessages.signInButton) }} <RightArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-else>
|
||||
<h1>{{ formatMessage(messages.signInWithLabel) }}</h1>
|
||||
|
||||
<section class="third-party">
|
||||
<ButtonStyled>
|
||||
<a :href="getAuthUrl('discord', redirectTarget)">
|
||||
<DiscordColorIcon />
|
||||
<span>Discord</span>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<a :href="getAuthUrl('github', redirectTarget)">
|
||||
<GitHubColorIcon />
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<a :href="getAuthUrl('microsoft', redirectTarget)">
|
||||
<MicrosoftColorIcon />
|
||||
<span>Microsoft</span>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<a :href="getAuthUrl('google', redirectTarget)">
|
||||
<GoogleColorIcon />
|
||||
<span>Google</span>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<a :href="getAuthUrl('steam', redirectTarget)">
|
||||
<SteamColorIcon />
|
||||
<span>Steam</span>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<a :href="getAuthUrl('gitlab', redirectTarget)">
|
||||
<GitLabColorIcon />
|
||||
<span>GitLab</span>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</section>
|
||||
|
||||
<h1>{{ formatMessage(messages.usePasswordLabel) }}</h1>
|
||||
|
||||
<section class="auth-form">
|
||||
<label for="email" hidden>{{ formatMessage(commonMessages.emailUsernameLabel) }}</label>
|
||||
<StyledInput
|
||||
id="email"
|
||||
v-model="email"
|
||||
:icon="MailIcon"
|
||||
type="text"
|
||||
inputmode="email"
|
||||
autocomplete="username"
|
||||
:placeholder="formatMessage(commonMessages.emailUsernameLabel)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
|
||||
<label for="password" hidden>{{ formatMessage(commonMessages.passwordLabel) }}</label>
|
||||
<StyledInput
|
||||
id="password"
|
||||
v-model="password"
|
||||
:icon="KeyIcon"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
:placeholder="formatMessage(commonMessages.passwordLabel)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
|
||||
<HCaptcha v-if="globals?.captcha_enabled" ref="captcha" v-model="token" />
|
||||
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
class="continue-btn centered-btn"
|
||||
:disabled="globals?.captcha_enabled ? !token : false"
|
||||
@click="beginPasswordSignIn()"
|
||||
>
|
||||
{{ formatMessage(commonMessages.signInButton) }} <RightArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<div class="auth-form__additional-options">
|
||||
<IntlFormatted :message-id="messages.additionalOptionsLabel">
|
||||
<template #forgot-password-link="{ children }">
|
||||
<NuxtLink
|
||||
class="text-link"
|
||||
:to="{
|
||||
path: '/auth/reset-password',
|
||||
query: route.query,
|
||||
}"
|
||||
>
|
||||
<component :is="() => children" />
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<template #create-account-link="{ children }">
|
||||
<NuxtLink
|
||||
class="text-link"
|
||||
:to="{
|
||||
path: '/auth/sign-up',
|
||||
query: route.query,
|
||||
}"
|
||||
>
|
||||
<component :is="() => children" />
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
<SignInView
|
||||
v-model:email="email"
|
||||
v-model:password="password"
|
||||
v-model:token="token"
|
||||
v-model:two-factor-code="twoFactorCode"
|
||||
:subtle-launcher-redirect-uri="subtleLauncherRedirectUri"
|
||||
:flow="flow"
|
||||
:redirect-target="redirectTarget"
|
||||
:route-query="route.query"
|
||||
:globals="globals"
|
||||
:on-password-sign-in="beginPasswordSignIn"
|
||||
:on-two-factor-sign-in="begin2FASignIn"
|
||||
:on-set-captcha-ref="setCaptchaRef"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DiscordColorIcon,
|
||||
GitHubColorIcon,
|
||||
GitLabColorIcon,
|
||||
GoogleColorIcon,
|
||||
KeyIcon,
|
||||
MailIcon,
|
||||
MicrosoftColorIcon,
|
||||
RightArrowIcon,
|
||||
SteamColorIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
IntlFormatted,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import type { LocationQueryValue } from 'vue-router'
|
||||
|
||||
import HCaptcha from '@/components/ui/HCaptcha.vue'
|
||||
import { getAuthUrl, getLauncherRedirectUrl } from '@/composables/auth.js'
|
||||
import SignInView from '@/components/ui/auth/SignIn.vue'
|
||||
import {
|
||||
getLauncherRedirectUrl,
|
||||
LAST_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
|
||||
PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
|
||||
promotePendingSignInOAuthProvider,
|
||||
} from '@/composables/auth.ts'
|
||||
|
||||
type AuthProvider = 'discord' | 'google' | 'github' | 'gitlab' | 'steam' | 'microsoft'
|
||||
|
||||
interface AuthGlobalsResponse {
|
||||
captcha_enabled?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface ApiErrorShape {
|
||||
data?: {
|
||||
description?: string
|
||||
}
|
||||
}
|
||||
|
||||
const getQueryString = (
|
||||
value: LocationQueryValue | LocationQueryValue[] | null | undefined,
|
||||
): string => {
|
||||
const firstValue = Array.isArray(value) ? value[0] : value
|
||||
return typeof firstValue === 'string' ? firstValue : ''
|
||||
}
|
||||
|
||||
const getErrorMessage = (error: unknown): string => {
|
||||
const apiError = error as ApiErrorShape
|
||||
if (typeof apiError?.data?.description === 'string') {
|
||||
return apiError.data.description
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -173,35 +72,10 @@ const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
additionalOptionsLabel: {
|
||||
id: 'auth.sign-in.additional-options',
|
||||
defaultMessage:
|
||||
'<forgot-password-link>Forgot password?</forgot-password-link> • <create-account-link>Create an account</create-account-link>',
|
||||
},
|
||||
signInWithLabel: {
|
||||
id: 'auth.sign-in.sign-in-with',
|
||||
defaultMessage: 'Sign in with',
|
||||
},
|
||||
signInTitle: {
|
||||
id: 'auth.sign-in.title',
|
||||
defaultMessage: 'Sign In',
|
||||
},
|
||||
twoFactorCodeInputPlaceholder: {
|
||||
id: 'auth.sign-in.2fa.placeholder',
|
||||
defaultMessage: 'Enter code...',
|
||||
},
|
||||
twoFactorCodeLabel: {
|
||||
id: 'auth.sign-in.2fa.label',
|
||||
defaultMessage: 'Enter two-factor code',
|
||||
},
|
||||
twoFactorCodeLabelDescription: {
|
||||
id: 'auth.sign-in.2fa.description',
|
||||
defaultMessage: 'Please enter a two-factor code to proceed.',
|
||||
},
|
||||
usePasswordLabel: {
|
||||
id: 'auth.sign-in.use-password',
|
||||
defaultMessage: 'Or use a password',
|
||||
},
|
||||
})
|
||||
|
||||
useHead({
|
||||
@@ -212,11 +86,35 @@ useHead({
|
||||
|
||||
const auth = await useAuth()
|
||||
const route = useNativeRoute()
|
||||
const pendingSignInOAuthProvider = useStorage<AuthProvider | null>(
|
||||
PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
|
||||
null,
|
||||
undefined,
|
||||
{ initOnMounted: true },
|
||||
)
|
||||
const lastSignInOAuthProvider = useStorage<AuthProvider | null>(
|
||||
LAST_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
|
||||
null,
|
||||
undefined,
|
||||
{ initOnMounted: true },
|
||||
)
|
||||
|
||||
const redirectTarget = route.query.redirect || ''
|
||||
const subtleLauncherRedirectUri = ref()
|
||||
if (route.query.state !== undefined) {
|
||||
await navigateTo(
|
||||
{
|
||||
path: '/auth/create/oauth',
|
||||
query: route.query,
|
||||
},
|
||||
{
|
||||
replace: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (route.query.code && !route.fullPath.includes('new_account=true')) {
|
||||
const redirectTarget = getQueryString(route.query.redirect)
|
||||
const subtleLauncherRedirectUri = ref<string>()
|
||||
|
||||
if (route.query.code) {
|
||||
await finishSignIn()
|
||||
}
|
||||
|
||||
@@ -224,9 +122,12 @@ if (auth.value.user) {
|
||||
await finishSignIn()
|
||||
}
|
||||
|
||||
const captcha = ref()
|
||||
const captcha = ref<{ reset?: () => void } | null>(null)
|
||||
const setCaptchaRef = (captchaRef: unknown) => {
|
||||
captcha.value = (captchaRef as { reset?: () => void } | null) ?? null
|
||||
}
|
||||
|
||||
const { data: globals } = useQuery({
|
||||
const { data: globals } = useQuery<AuthGlobalsResponse>({
|
||||
queryKey: ['auth-globals'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
@@ -242,9 +143,11 @@ const email = ref('')
|
||||
const password = ref('')
|
||||
const token = ref('')
|
||||
|
||||
const flow = ref(route.query.flow)
|
||||
const flow = ref(getQueryString(route.query.flow))
|
||||
|
||||
async function beginPasswordSignIn() {
|
||||
pendingSignInOAuthProvider.value = null
|
||||
lastSignInOAuthProvider.value = null
|
||||
startLoading()
|
||||
try {
|
||||
const res = await client.labrinth.auth_v2.login({
|
||||
@@ -261,41 +164,44 @@ async function beginPasswordSignIn() {
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data ? err.data.description : err,
|
||||
text: getErrorMessage(err),
|
||||
type: 'error',
|
||||
})
|
||||
captcha.value?.reset()
|
||||
captcha.value?.reset?.()
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
const twoFactorCode = ref(null)
|
||||
const twoFactorCode = ref('')
|
||||
async function begin2FASignIn() {
|
||||
startLoading()
|
||||
try {
|
||||
const res = await client.labrinth.auth_v2.login2FA({
|
||||
flow: flow.value,
|
||||
code: twoFactorCode.value ? twoFactorCode.value.toString() : twoFactorCode.value,
|
||||
code: twoFactorCode.value,
|
||||
})
|
||||
|
||||
await finishSignIn(res.session)
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data ? err.data.description : err,
|
||||
text: getErrorMessage(err),
|
||||
type: 'error',
|
||||
})
|
||||
captcha.value?.reset()
|
||||
captcha.value?.reset?.()
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
async function finishSignIn(token) {
|
||||
async function finishSignIn(sessionToken?: string | null) {
|
||||
if (route.query.launcher) {
|
||||
let token = sessionToken
|
||||
if (!token) {
|
||||
token = auth.value.token
|
||||
}
|
||||
|
||||
promotePendingSignInOAuthProvider()
|
||||
|
||||
const redirectUrl = `${getLauncherRedirectUrl(route)}/?code=${token}`
|
||||
|
||||
if (redirectUrl.startsWith('https://launcher-files.modrinth.com/')) {
|
||||
@@ -316,14 +222,16 @@ async function finishSignIn(token) {
|
||||
return
|
||||
}
|
||||
|
||||
if (token) {
|
||||
await useAuth(token)
|
||||
if (sessionToken) {
|
||||
await useAuth(sessionToken)
|
||||
await useUser()
|
||||
queryClient.clear()
|
||||
}
|
||||
|
||||
promotePendingSignInOAuthProvider()
|
||||
|
||||
if (route.query.redirect) {
|
||||
const redirect = decodeURIComponent(route.query.redirect)
|
||||
const redirect = decodeURIComponent(getQueryString(route.query.redirect))
|
||||
await navigateTo(redirect, {
|
||||
replace: true,
|
||||
})
|
||||
|
||||
@@ -1,171 +1,81 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>{{ formatMessage(messages.signUpWithTitle) }}</h1>
|
||||
|
||||
<section class="third-party">
|
||||
<ButtonStyled>
|
||||
<a class="discord-btn" :href="getAuthUrl('discord', redirectTarget)">
|
||||
<DiscordColorIcon />
|
||||
<span>Discord</span>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<a :href="getAuthUrl('github', redirectTarget)">
|
||||
<GitHubColorIcon />
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<a :href="getAuthUrl('microsoft', redirectTarget)">
|
||||
<MicrosoftColorIcon />
|
||||
<span>Microsoft</span>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<a :href="getAuthUrl('google', redirectTarget)">
|
||||
<GoogleColorIcon />
|
||||
<span>Google</span>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<a :href="getAuthUrl('steam', redirectTarget)">
|
||||
<SteamColorIcon />
|
||||
<span>Steam</span>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<a :href="getAuthUrl('gitlab', redirectTarget)">
|
||||
<GitLabColorIcon />
|
||||
<span>GitLab</span>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</section>
|
||||
|
||||
<h1>{{ formatMessage(messages.createAccountTitle) }}</h1>
|
||||
|
||||
<section class="auth-form">
|
||||
<label for="email" hidden>{{ formatMessage(commonMessages.emailLabel) }}</label>
|
||||
<StyledInput
|
||||
id="email"
|
||||
v-model="email"
|
||||
:icon="MailIcon"
|
||||
type="email"
|
||||
autocomplete="username"
|
||||
:placeholder="formatMessage(commonMessages.emailLabel)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
|
||||
<label for="username" hidden>{{ formatMessage(commonMessages.usernameLabel) }}</label>
|
||||
<StyledInput
|
||||
id="username"
|
||||
v-model="username"
|
||||
:icon="UserIcon"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
:placeholder="formatMessage(commonMessages.usernameLabel)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
|
||||
<label for="password" hidden>{{ formatMessage(commonMessages.passwordLabel) }}</label>
|
||||
<StyledInput
|
||||
id="password"
|
||||
v-model="password"
|
||||
:icon="KeyIcon"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:placeholder="formatMessage(commonMessages.passwordLabel)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
|
||||
<label for="confirm-password" hidden>{{ formatMessage(commonMessages.passwordLabel) }}</label>
|
||||
<StyledInput
|
||||
id="confirm-password"
|
||||
v-model="confirmPassword"
|
||||
:icon="KeyIcon"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:placeholder="formatMessage(commonMessages.confirmPasswordLabel)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
v-model="subscribe"
|
||||
class="subscribe-btn"
|
||||
:label="formatMessage(messages.subscribeLabel)"
|
||||
:description="formatMessage(messages.subscribeLabel)"
|
||||
/>
|
||||
|
||||
<p v-if="!route.query.launcher">
|
||||
<IntlFormatted :message-id="messages.legalDisclaimer">
|
||||
<template #terms-link="{ children }">
|
||||
<NuxtLink to="/legal/terms" class="text-link">
|
||||
<component :is="() => children" />
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<template #privacy-policy-link="{ children }">
|
||||
<NuxtLink to="/legal/privacy" class="text-link">
|
||||
<component :is="() => children" />
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
|
||||
<HCaptcha v-if="globals?.captcha_enabled" ref="captcha" v-model="token" />
|
||||
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
class="continue-btn centered-btn"
|
||||
:disabled="globals?.captcha_enabled ? !token : false"
|
||||
@click="createAccount"
|
||||
>
|
||||
{{ formatMessage(messages.createAccountButton) }} <RightArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<div class="auth-form__additional-options">
|
||||
{{ formatMessage(messages.alreadyHaveAccountLabel) }}
|
||||
<NuxtLink
|
||||
class="text-link"
|
||||
:to="{
|
||||
path: '/auth/sign-in',
|
||||
query: route.query,
|
||||
}"
|
||||
>
|
||||
{{ formatMessage(commonMessages.signInButton) }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<SignUpView
|
||||
v-if="!isCreateAccountStep"
|
||||
v-model:email="email"
|
||||
v-model:password="password"
|
||||
:redirect-target="redirectTarget"
|
||||
:show-other-options="showOtherOptions"
|
||||
:route-query="route.query"
|
||||
:on-toggle-other-options="toggleOtherOptions"
|
||||
:on-continue-with-email="continueWithEmail"
|
||||
/>
|
||||
<CreateAccountView
|
||||
v-else
|
||||
v-model:date-of-birth="dateOfBirth"
|
||||
v-model:username="username"
|
||||
v-model:token="token"
|
||||
v-model:subscribe="subscribe"
|
||||
:globals="globals"
|
||||
:on-complete-sign-up="createAccount"
|
||||
:on-set-captcha-ref="setCaptchaRef"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DiscordColorIcon,
|
||||
GitHubColorIcon,
|
||||
GitLabColorIcon,
|
||||
GoogleColorIcon,
|
||||
KeyIcon,
|
||||
MailIcon,
|
||||
MicrosoftColorIcon,
|
||||
RightArrowIcon,
|
||||
SteamColorIcon,
|
||||
UserIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
IntlFormatted,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import type { LocationQueryValue } from 'vue-router'
|
||||
|
||||
import HCaptcha from '@/components/ui/HCaptcha.vue'
|
||||
import { getAuthUrl } from '@/composables/auth.js'
|
||||
import CreateAccountView from '@/components/ui/auth/CreateAccount.vue'
|
||||
import SignUpView from '@/components/ui/auth/SignUp.vue'
|
||||
import {
|
||||
LAST_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
|
||||
PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
|
||||
promotePendingSignInOAuthProvider,
|
||||
} from '@/composables/auth.ts'
|
||||
|
||||
interface AuthGlobalsResponse {
|
||||
captcha_enabled?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface ApiErrorShape {
|
||||
data?: {
|
||||
description?: string
|
||||
error?: string
|
||||
}
|
||||
v1Error?: {
|
||||
error?: string
|
||||
}
|
||||
responseData?: {
|
||||
error?: string
|
||||
}
|
||||
}
|
||||
|
||||
const getQueryString = (
|
||||
value: LocationQueryValue | LocationQueryValue[] | null | undefined,
|
||||
): string => {
|
||||
const firstValue = Array.isArray(value) ? value[0] : value
|
||||
return typeof firstValue === 'string' ? firstValue : ''
|
||||
}
|
||||
|
||||
const getErrorMessage = (error: unknown): string => {
|
||||
const apiError = error as ApiErrorShape
|
||||
if (typeof apiError?.data?.description === 'string') {
|
||||
return apiError.data.description
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
@@ -176,30 +86,13 @@ const messages = defineMessages({
|
||||
id: 'auth.sign-up.title',
|
||||
defaultMessage: 'Sign Up',
|
||||
},
|
||||
signUpWithTitle: {
|
||||
id: 'auth.sign-up.title.sign-up-with',
|
||||
defaultMessage: 'Sign up with',
|
||||
ageRequirementWarningTitle: {
|
||||
id: 'auth.sign-up.age-requirement.warning-title',
|
||||
defaultMessage: 'Age requirement',
|
||||
},
|
||||
createAccountTitle: {
|
||||
id: 'auth.sign-up.title.create-account',
|
||||
defaultMessage: 'Or create an account yourself',
|
||||
},
|
||||
subscribeLabel: {
|
||||
id: 'auth.sign-up.subscribe.label',
|
||||
defaultMessage: 'Subscribe to updates about Modrinth',
|
||||
},
|
||||
legalDisclaimer: {
|
||||
id: 'auth.sign-up.legal-dislaimer',
|
||||
defaultMessage:
|
||||
"By creating an account, you agree to Modrinth's <terms-link>Terms</terms-link> and <privacy-policy-link>Privacy Policy</privacy-policy-link>.",
|
||||
},
|
||||
createAccountButton: {
|
||||
id: 'auth.sign-up.action.create-account',
|
||||
defaultMessage: 'Create account',
|
||||
},
|
||||
alreadyHaveAccountLabel: {
|
||||
id: 'auth.sign-up.sign-in-option.title',
|
||||
defaultMessage: 'Already have an account?',
|
||||
under13HelperText: {
|
||||
id: 'auth.create-account.date-of-birth.under13-helper',
|
||||
defaultMessage: 'You cannot create an account at Modrinth unless you are over 13 years old.',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -209,16 +102,36 @@ useHead({
|
||||
|
||||
const auth = await useAuth()
|
||||
const route = useNativeRoute()
|
||||
const pendingSignInOAuthProvider = useStorage(
|
||||
PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
|
||||
null,
|
||||
undefined,
|
||||
{ initOnMounted: true },
|
||||
)
|
||||
const lastSignInOAuthProvider = useStorage(
|
||||
LAST_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
|
||||
null,
|
||||
undefined,
|
||||
{ initOnMounted: true },
|
||||
)
|
||||
|
||||
const redirectTarget = route.query.redirect
|
||||
const redirectTarget = getQueryString(route.query.redirect)
|
||||
const showOtherOptions = ref(false)
|
||||
const isCreateAccountStep = ref(false)
|
||||
|
||||
if (auth.value.user) {
|
||||
await navigateTo('/dashboard')
|
||||
}
|
||||
|
||||
const captcha = ref()
|
||||
const captcha = ref<{ reset?: () => void } | null>(null)
|
||||
const setCaptchaRef = (captchaRef: unknown) => {
|
||||
captcha.value = (captchaRef as { reset?: () => void } | null) ?? null
|
||||
}
|
||||
const toggleOtherOptions = () => {
|
||||
showOtherOptions.value = !showOtherOptions.value
|
||||
}
|
||||
|
||||
const { data: globals } = useQuery({
|
||||
const { data: globals } = useQuery<AuthGlobalsResponse>({
|
||||
queryKey: ['auth-globals'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
@@ -231,29 +144,117 @@ const { data: globals } = useQuery({
|
||||
})
|
||||
|
||||
const email = ref('')
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const dateOfBirth = ref('')
|
||||
const username = ref('')
|
||||
const token = ref('')
|
||||
const subscribe = ref(false)
|
||||
const USERNAME_MAX_LENGTH = 39
|
||||
const MAX_USERNAME_SUFFIX_RETRIES = 10
|
||||
const MAX_RANDOM_USERNAME_ATTEMPTS = 10
|
||||
|
||||
async function continueWithEmail() {
|
||||
pendingSignInOAuthProvider.value = null
|
||||
lastSignInOAuthProvider.value = null
|
||||
startLoading()
|
||||
try {
|
||||
const generatedUsername = await findAvailableGeneratedUsername()
|
||||
|
||||
token.value = ''
|
||||
username.value = generatedUsername
|
||||
isCreateAccountStep.value = true
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: getErrorMessage(err),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
function generateUsernameFromEmail(emailAddress: string): string {
|
||||
const [localPart = ''] = emailAddress.trim().toLowerCase().split('@')
|
||||
const sanitized = localPart
|
||||
.replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
|
||||
return (sanitized || 'user').slice(0, USERNAME_MAX_LENGTH)
|
||||
}
|
||||
|
||||
async function findAvailableGeneratedUsername(): Promise<string> {
|
||||
const baseUsername = generateUsernameFromEmail(email.value)
|
||||
|
||||
for (let suffixAttempt = 0; suffixAttempt <= MAX_USERNAME_SUFFIX_RETRIES; suffixAttempt++) {
|
||||
const candidateUsername = appendUsernameSuffix(baseUsername, suffixAttempt)
|
||||
|
||||
try {
|
||||
await validateCreateAccountCandidate(candidateUsername)
|
||||
return candidateUsername
|
||||
} catch (err) {
|
||||
if (!isUsernameTakenValidationError(err)) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return await findAvailableRandomUsername()
|
||||
}
|
||||
|
||||
function appendUsernameSuffix(baseUsername: string, suffixAttempt: number): string {
|
||||
if (suffixAttempt === 0) {
|
||||
return baseUsername
|
||||
}
|
||||
|
||||
const suffix = String(suffixAttempt)
|
||||
const maxBaseLength = Math.max(1, USERNAME_MAX_LENGTH - suffix.length)
|
||||
return `${baseUsername.slice(0, maxBaseLength)}${suffix}`
|
||||
}
|
||||
|
||||
function generateRandomUsername(): string {
|
||||
const randomSuffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`
|
||||
const base = `user_${randomSuffix}`
|
||||
return base.slice(0, USERNAME_MAX_LENGTH)
|
||||
}
|
||||
|
||||
async function findAvailableRandomUsername(): Promise<string> {
|
||||
for (let attempt = 0; attempt < MAX_RANDOM_USERNAME_ATTEMPTS; attempt++) {
|
||||
const candidateUsername = generateRandomUsername()
|
||||
|
||||
try {
|
||||
await validateCreateAccountCandidate(candidateUsername)
|
||||
return candidateUsername
|
||||
} catch (err) {
|
||||
if (!isUsernameTakenValidationError(err)) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Unable to find an available username. Please choose one manually.')
|
||||
}
|
||||
|
||||
async function validateCreateAccountCandidate(candidateUsername: string): Promise<void> {
|
||||
await client.labrinth.auth_v2.validateCreateAccount({
|
||||
username: candidateUsername,
|
||||
password: password.value,
|
||||
email: email.value,
|
||||
})
|
||||
}
|
||||
|
||||
function isUsernameTakenValidationError(error: unknown): boolean {
|
||||
const apiError = error as ApiErrorShape
|
||||
const errorCode =
|
||||
apiError?.data?.error ?? apiError?.v1Error?.error ?? apiError?.responseData?.error
|
||||
return errorCode === 'username_taken'
|
||||
}
|
||||
|
||||
async function createAccount() {
|
||||
startLoading()
|
||||
try {
|
||||
if (confirmPassword.value !== password.value) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: formatMessage({
|
||||
id: 'auth.sign-up.notification.password-mismatch.text',
|
||||
defaultMessage: 'Passwords do not match!',
|
||||
}),
|
||||
type: 'error',
|
||||
})
|
||||
captcha.value?.reset()
|
||||
}
|
||||
|
||||
const res = await client.labrinth.auth_v2.createAccount({
|
||||
username: username.value,
|
||||
username: username.value.trim() || generateUsernameFromEmail(email.value),
|
||||
password: password.value,
|
||||
email: email.value,
|
||||
challenge: token.value,
|
||||
@@ -263,23 +264,25 @@ async function createAccount() {
|
||||
await useAuth(res.session)
|
||||
await useUser()
|
||||
|
||||
promotePendingSignInOAuthProvider()
|
||||
|
||||
if (route.query.launcher) {
|
||||
await navigateTo({ path: '/auth/sign-in', query: route.query })
|
||||
return
|
||||
}
|
||||
|
||||
if (route.query.redirect) {
|
||||
await navigateTo(route.query.redirect)
|
||||
await navigateTo(getQueryString(route.query.redirect))
|
||||
} else {
|
||||
await navigateTo('/dashboard')
|
||||
}
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data ? err.data.description : err,
|
||||
text: getErrorMessage(err),
|
||||
type: 'error',
|
||||
})
|
||||
captcha.value?.reset()
|
||||
captcha.value?.reset?.()
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
@@ -1,78 +1,126 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="universal-card flex w-full max-w-[28rem] flex-col gap-6 border border-solid border-surface-5"
|
||||
>
|
||||
<template v-if="auth.user && auth.user.email_verified && !success">
|
||||
<h1>{{ formatMessage(alreadyVerifiedMessages.title) }}</h1>
|
||||
<h1 class="m-0 mx-auto text-xl font-semibold text-contrast">
|
||||
{{ formatMessage(alreadyVerifiedMessages.title) }}
|
||||
</h1>
|
||||
|
||||
<section class="auth-form">
|
||||
<p>{{ formatMessage(alreadyVerifiedMessages.description) }}</p>
|
||||
<Admonition type="success">
|
||||
{{ formatMessage(alreadyVerifiedMessages.description) }}
|
||||
</Admonition>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<ButtonStyled>
|
||||
<NuxtLink to="/settings/account">
|
||||
<NuxtLink class="shadow-none" to="/settings/account">
|
||||
<SettingsIcon /> {{ formatMessage(messages.accountSettings) }}
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
</section>
|
||||
<ButtonStyled color="brand">
|
||||
<NuxtLink to="/discover/mods">
|
||||
{{ formatMessage(messages.discoverMods) }}
|
||||
<RightArrowIcon />
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="success">
|
||||
<h1>{{ formatMessage(postVerificationMessages.title) }}</h1>
|
||||
<h1 class="m-0 mx-auto text-xl font-semibold text-contrast">
|
||||
{{ formatMessage(postVerificationMessages.title) }}
|
||||
</h1>
|
||||
|
||||
<section class="auth-form">
|
||||
<p>{{ formatMessage(postVerificationMessages.description) }}</p>
|
||||
<Admonition type="success">
|
||||
{{ formatMessage(postVerificationMessages.description) }}
|
||||
</Admonition>
|
||||
|
||||
<ButtonStyled v-if="auth.user">
|
||||
<NuxtLink to="/settings/account">
|
||||
<SettingsIcon /> {{ formatMessage(messages.accountSettings) }}
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else>
|
||||
<NuxtLink to="/auth/sign-in">
|
||||
{{ formatMessage(commonMessages.signInButton) }}
|
||||
<RightArrowIcon />
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
</section>
|
||||
<template v-if="auth.user">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<ButtonStyled>
|
||||
<NuxtLink to="/settings/account">
|
||||
<SettingsIcon /> {{ formatMessage(messages.accountSettings) }}
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<NuxtLink to="/discover/mods">
|
||||
{{ formatMessage(messages.discoverMods) }}
|
||||
<RightArrowIcon />
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
<ButtonStyled v-else color="brand">
|
||||
<NuxtLink to="/auth/sign-in" class="!w-full">
|
||||
{{ formatMessage(commonMessages.signInButton) }}
|
||||
<RightArrowIcon />
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<h1>{{ formatMessage(failedVerificationMessages.title) }}</h1>
|
||||
<h1 class="m-0 mx-auto text-xl font-semibold text-contrast">
|
||||
{{ formatMessage(failedVerificationMessages.title) }}
|
||||
</h1>
|
||||
|
||||
<section class="auth-form">
|
||||
<p>
|
||||
<template v-if="auth.user">
|
||||
{{ formatMessage(failedVerificationMessages.loggedInDescription) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ formatMessage(failedVerificationMessages.description) }}
|
||||
</template>
|
||||
</p>
|
||||
<Admonition v-if="auth.user" type="warning">
|
||||
{{ formatMessage(failedVerificationMessages.loggedInDescription) }}
|
||||
</Admonition>
|
||||
<Admonition v-else type="warning">
|
||||
{{ formatMessage(failedVerificationMessages.description) }}
|
||||
</Admonition>
|
||||
|
||||
<ButtonStyled v-if="auth.user" color="brand">
|
||||
<button @click="handleResendEmailVerification">
|
||||
{{ formatMessage(failedVerificationMessages.action) }}
|
||||
<RightArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled v-else color="brand">
|
||||
<NuxtLink to="/auth/sign-in">
|
||||
{{ formatMessage(commonMessages.signInButton) }}
|
||||
<RightArrowIcon />
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
</section>
|
||||
<ButtonStyled v-if="auth.user" color="brand">
|
||||
<button class="!w-full" @click="handleResendEmailVerification">
|
||||
{{ formatMessage(failedVerificationMessages.action) }}
|
||||
<RightArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else color="brand">
|
||||
<NuxtLink to="/auth/sign-in" class="!w-full">
|
||||
{{ formatMessage(commonMessages.signInButton) }}
|
||||
<RightArrowIcon />
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { RightArrowIcon, SettingsIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { LocationQueryValue } from 'vue-router'
|
||||
|
||||
interface ApiErrorShape {
|
||||
data?: {
|
||||
description?: string
|
||||
error?: string
|
||||
}
|
||||
}
|
||||
|
||||
const getQueryString = (
|
||||
value: LocationQueryValue | LocationQueryValue[] | null | undefined,
|
||||
): string => {
|
||||
const firstValue = Array.isArray(value) ? value[0] : value
|
||||
return typeof firstValue === 'string' ? firstValue : ''
|
||||
}
|
||||
|
||||
const getErrorMessage = (error: unknown): string => {
|
||||
const apiError = error as ApiErrorShape
|
||||
if (typeof apiError?.data?.description === 'string') {
|
||||
return apiError.data.description
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -86,6 +134,22 @@ const messages = defineMessages({
|
||||
id: 'auth.verify-email.action.account-settings',
|
||||
defaultMessage: 'Account settings',
|
||||
},
|
||||
discoverMods: {
|
||||
id: 'auth.verify-email.action.discover-mods',
|
||||
defaultMessage: 'Discover mods',
|
||||
},
|
||||
emailSentNotificationTitle: {
|
||||
id: 'auth.verify-email.notification.email-sent.title',
|
||||
defaultMessage: 'Email sent',
|
||||
},
|
||||
emailSentNotificationDescription: {
|
||||
id: 'auth.verify-email.notification.email-sent.description',
|
||||
defaultMessage: 'An email with a link to verify your account has been sent to {email}.',
|
||||
},
|
||||
errorOccurredTitle: {
|
||||
id: 'auth.verify-email.notification.error-occurred.title',
|
||||
defaultMessage: 'An error occurred',
|
||||
},
|
||||
})
|
||||
|
||||
const alreadyVerifiedMessages = defineMessages({
|
||||
@@ -142,13 +206,13 @@ const route = useNativeRoute()
|
||||
|
||||
if (route.query.flow) {
|
||||
try {
|
||||
const emailVerified = useState('emailVerified', () => null)
|
||||
const emailVerified = useState<boolean | null>('emailVerified', () => null)
|
||||
|
||||
if (emailVerified.value === null) {
|
||||
await useBaseFetch('auth/email/verify', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
flow: route.query.flow,
|
||||
flow: getQueryString(route.query.flow),
|
||||
},
|
||||
})
|
||||
emailVerified.value = true
|
||||
@@ -171,14 +235,16 @@ async function handleResendEmailVerification() {
|
||||
try {
|
||||
await resendVerifyEmail()
|
||||
addNotification({
|
||||
title: 'Email sent',
|
||||
text: `An email with a link to verify your account has been sent to ${auth.value.user.email}.`,
|
||||
title: formatMessage(messages.emailSentNotificationTitle),
|
||||
text: formatMessage(messages.emailSentNotificationDescription, {
|
||||
email: auth.value.user?.email ?? '',
|
||||
}),
|
||||
type: 'success',
|
||||
})
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: 'An error occurred',
|
||||
text: err.data.description,
|
||||
title: formatMessage(messages.errorOccurredTitle),
|
||||
text: getErrorMessage(err),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
<template>
|
||||
<div class="welcome-box has-bot">
|
||||
<img :src="WavingRinthbot" alt="Waving Modrinth Bot" class="welcome-box__waving-bot" />
|
||||
<div class="welcome-box__top-glow" />
|
||||
<div class="welcome-box__body">
|
||||
<h1 class="welcome-box__title">
|
||||
{{ formatMessage(messages.welcomeLongTitle) }}
|
||||
</h1>
|
||||
|
||||
<p class="welcome-box__subtitle">
|
||||
<IntlFormatted :message-id="messages.welcomeDescription">
|
||||
<template #bold="{ children }">
|
||||
<strong>
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
</strong>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
|
||||
<Checkbox
|
||||
v-model="subscribe"
|
||||
class="subscribe-btn"
|
||||
:label="formatMessage(messages.subscribeCheckbox)"
|
||||
:description="formatMessage(messages.subscribeCheckbox)"
|
||||
/>
|
||||
|
||||
<ButtonStyled color="brand">
|
||||
<button class="centered-btn" @click="continueSignUp">
|
||||
{{ formatMessage(commonMessages.continueButton) }}
|
||||
<RightArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<p class="tos-text">
|
||||
<IntlFormatted :message-id="messages.tosLabel">
|
||||
<template #terms-link="{ children }">
|
||||
<NuxtLink to="/legal/terms" class="text-link">
|
||||
<component :is="() => children" />
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<template #privacy-policy-link="{ children }">
|
||||
<NuxtLink to="/legal/privacy" class="text-link">
|
||||
<component :is="() => children" />
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { RightArrowIcon, WavingRinthbot } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
IntlFormatted,
|
||||
normalizeChildren,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const route = useRoute()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
subscribeCheckbox: {
|
||||
id: 'auth.welcome.checkbox.subscribe',
|
||||
defaultMessage: 'Subscribe to updates about Modrinth',
|
||||
},
|
||||
tosLabel: {
|
||||
id: 'auth.welcome.label.tos',
|
||||
defaultMessage:
|
||||
"By creating an account, you have agreed to Modrinth's <terms-link>Terms</terms-link> and <privacy-policy-link>Privacy Policy</privacy-policy-link>.",
|
||||
},
|
||||
welcomeDescription: {
|
||||
id: 'auth.welcome.description',
|
||||
defaultMessage:
|
||||
'You’re now part of the awesome community of creators & explorers already building, downloading, and staying up-to-date with amazing mods.',
|
||||
},
|
||||
welcomeLongTitle: {
|
||||
id: 'auth.welcome.long-title',
|
||||
defaultMessage: 'Welcome to Modrinth!',
|
||||
},
|
||||
welcomeTitle: {
|
||||
id: 'auth.welcome.title',
|
||||
defaultMessage: 'Welcome',
|
||||
},
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: () => `${formatMessage(messages.welcomeTitle)} - Modrinth`,
|
||||
})
|
||||
|
||||
const subscribe = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
await useAuth(route.query.authToken)
|
||||
await useUser()
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
async function continueSignUp() {
|
||||
if (subscribe.value) {
|
||||
try {
|
||||
await useBaseFetch('auth/email/subscribe', {
|
||||
method: 'POST',
|
||||
})
|
||||
} catch {
|
||||
// Ignored
|
||||
}
|
||||
}
|
||||
|
||||
if (route.query.redirect) {
|
||||
await navigateTo(route.query.redirect)
|
||||
} else {
|
||||
await navigateTo('/dashboard')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.welcome-box {
|
||||
background-color: var(--color-raised-bg);
|
||||
border-radius: var(--size-rounded-lg);
|
||||
padding: 1.75rem 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
box-shadow: var(--shadow-card);
|
||||
position: relative;
|
||||
|
||||
&.has-bot {
|
||||
margin-block: 120px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
a {
|
||||
color: var(--color-brand);
|
||||
font-weight: var(--weight-bold);
|
||||
&:hover,
|
||||
&:focus {
|
||||
filter: brightness(1.125);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
&__waving-bot {
|
||||
--bot-height: 112px;
|
||||
position: absolute;
|
||||
top: calc(-1 * var(--bot-height));
|
||||
right: 5rem;
|
||||
height: var(--bot-height);
|
||||
width: auto;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
--bot-height: 70px;
|
||||
right: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__top-glow {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
opacity: 0.4;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent 2rem,
|
||||
var(--color-green) calc(100% - 13rem),
|
||||
var(--color-green) calc(100% - 5rem),
|
||||
transparent calc(100% - 2rem)
|
||||
);
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: var(--text-32);
|
||||
font-weight: var(--weight-extrabold);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-size: var(--text-18);
|
||||
}
|
||||
|
||||
.tos-text {
|
||||
font-size: var(--text-14);
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -57,7 +57,6 @@ import { Avatar, ButtonStyled, defineMessages, injectModrinthClient, useVIntl }
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
|
||||
import OrganizationCreateModal from '~/components/ui/create/OrganizationCreateModal.vue'
|
||||
import { useAuth } from '~/composables/auth.js'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ function mapExternalProject(
|
||||
exceptions: project.exceptions,
|
||||
proof: project.proof,
|
||||
flame_project_id: project.flame_project_id,
|
||||
files: project.linked_files,
|
||||
files: project.linked_files ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -149,27 +149,34 @@
|
||||
<div class="card flex-card">
|
||||
<h2>Members</h2>
|
||||
<div class="details-list">
|
||||
<template v-for="member in acceptedMembers" :key="member?.user.id">
|
||||
<nuxt-link
|
||||
class="details-list__item details-list__item--type-large"
|
||||
:to="`/user/${member?.user?.username}`"
|
||||
>
|
||||
<Avatar :src="member?.user.avatar_url" circle />
|
||||
<div class="rows">
|
||||
<span class="flex items-center gap-1">
|
||||
{{ member?.user?.username }}
|
||||
<CrownIcon
|
||||
v-if="member?.is_owner"
|
||||
v-tooltip="'Organization owner'"
|
||||
class="text-brand-orange"
|
||||
/>
|
||||
</span>
|
||||
<span class="details-list__item__text--style-secondary">
|
||||
{{ member?.role ? member.role : 'Member' }}
|
||||
</span>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</template>
|
||||
<nuxt-link
|
||||
v-for="member in acceptedMembers"
|
||||
:key="`member-${member?.user?.id}`"
|
||||
class="group flex w-fit items-center gap-2 leading-[1.2] text-primary"
|
||||
:to="`/user/${member?.user?.username}`"
|
||||
>
|
||||
<Avatar
|
||||
:src="member.user.avatar_url"
|
||||
:alt="member.user.username"
|
||||
size="32px"
|
||||
circle
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<span class="flex w-full flex-nowrap items-center gap-1 group-hover:underline">
|
||||
<span class="min-w-0 overflow-hidden truncate font-normal text-contrast">{{
|
||||
member.user.username
|
||||
}}</span>
|
||||
<CrownIcon
|
||||
v-if="member.is_owner"
|
||||
v-tooltip="'Organization owner'"
|
||||
class="text-brand-orange"
|
||||
/>
|
||||
</span>
|
||||
<span class="text-sm font-normal">
|
||||
{{ member?.role ? member.role : 'Member' }}
|
||||
</span>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -284,11 +284,11 @@ import {
|
||||
VersionIcon,
|
||||
XCircleIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { defineMessage } from '@modrinth/ui'
|
||||
import {
|
||||
AutoLink,
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
defineMessage,
|
||||
defineMessages,
|
||||
formatReportItemType,
|
||||
injectNotificationManager,
|
||||
|
||||
@@ -9,23 +9,30 @@
|
||||
:has-to-type="true"
|
||||
@proceed="deleteAccount"
|
||||
/>
|
||||
<Modal
|
||||
<NewModal
|
||||
ref="changeEmailModal"
|
||||
width="500px"
|
||||
:header="`${auth.user.email ? formatMessage(messages.changeEmailHeaderChange) : formatMessage(messages.changeEmailHeaderAdd)}`"
|
||||
>
|
||||
<div class="universal-modal">
|
||||
<p>{{ formatMessage(messages.emailNotPublicNotice) }}</p>
|
||||
<label for="email-input">
|
||||
<span class="label__title">{{ formatMessage(messages.emailAddressLabel) }}</span>
|
||||
</label>
|
||||
<StyledInput
|
||||
id="email-input"
|
||||
v-model="email"
|
||||
:maxlength="2048"
|
||||
type="email"
|
||||
:placeholder="formatMessage(messages.emailAddressPlaceholder)"
|
||||
@keyup.enter="saveEmail()"
|
||||
/>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Admonition type="info">
|
||||
{{ formatMessage(messages.emailNotPublicNotice) }}
|
||||
</Admonition>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label for="email-input">
|
||||
<span class="text-md font-semibold text-contrast">{{
|
||||
formatMessage(messages.emailAddressLabel)
|
||||
}}</span>
|
||||
</label>
|
||||
<StyledInput
|
||||
id="email-input"
|
||||
v-model="email"
|
||||
:maxlength="2048"
|
||||
type="email"
|
||||
:placeholder="formatMessage(messages.emailAddressPlaceholder)"
|
||||
@keyup.enter="saveEmail()"
|
||||
/>
|
||||
</div>
|
||||
<div class="input-group push-right">
|
||||
<ButtonStyled>
|
||||
<button @click="$refs.changeEmailModal.hide()">
|
||||
@@ -41,9 +48,10 @@
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
</NewModal>
|
||||
<NewModal
|
||||
ref="managePasswordModal"
|
||||
width="600px"
|
||||
:header="`${
|
||||
removePasswordMode
|
||||
? formatMessage(messages.passwordHeaderRemove)
|
||||
@@ -52,60 +60,74 @@
|
||||
: formatMessage(messages.passwordHeaderAdd)
|
||||
}`"
|
||||
>
|
||||
<div class="universal-modal">
|
||||
<ul
|
||||
v-if="newPassword !== confirmNewPassword && confirmNewPassword.length > 0"
|
||||
class="known-errors"
|
||||
>
|
||||
<li>{{ formatMessage(messages.passwordsDoNotMatchError) }}</li>
|
||||
</ul>
|
||||
<label v-if="removePasswordMode" for="old-password">
|
||||
<span class="label__title">{{ formatMessage(messages.confirmPasswordLabel) }}</span>
|
||||
<span class="label__description">{{
|
||||
formatMessage(messages.confirmPasswordDescription)
|
||||
}}</span>
|
||||
</label>
|
||||
<label v-else-if="auth.user.has_password" for="old-password">
|
||||
<span class="label__title">{{ formatMessage(messages.oldPasswordLabel) }}</span>
|
||||
</label>
|
||||
<StyledInput
|
||||
v-if="auth.user.has_password"
|
||||
id="old-password"
|
||||
v-model="oldPassword"
|
||||
:maxlength="2048"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
:placeholder="
|
||||
removePasswordMode
|
||||
? formatMessage(messages.confirmPasswordPlaceholder)
|
||||
: formatMessage(messages.oldPasswordPlaceholder)
|
||||
"
|
||||
/>
|
||||
<template v-if="!removePasswordMode">
|
||||
<label for="new-password"
|
||||
><span class="label__title">{{ formatMessage(messages.newPasswordLabel) }}</span></label
|
||||
>
|
||||
<StyledInput
|
||||
id="new-password"
|
||||
v-model="newPassword"
|
||||
:maxlength="2048"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:placeholder="formatMessage(messages.newPasswordPlaceholder)"
|
||||
/>
|
||||
<label for="confirm-new-password">
|
||||
<span class="label__title">{{ formatMessage(messages.confirmNewPasswordLabel) }}</span>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div v-if="auth.user.has_password" class="flex flex-col gap-2.5">
|
||||
<label v-if="removePasswordMode" for="old-password">
|
||||
<span class="text-md font-semibold text-contrast">{{
|
||||
formatMessage(messages.confirmPasswordLabel)
|
||||
}}</span>
|
||||
</label>
|
||||
<label v-else for="old-password">
|
||||
<span class="text-md font-semibold text-contrast">{{
|
||||
formatMessage(messages.oldPasswordLabel)
|
||||
}}</span>
|
||||
</label>
|
||||
<StyledInput
|
||||
id="confirm-new-password"
|
||||
v-model="confirmNewPassword"
|
||||
id="old-password"
|
||||
v-model="oldPassword"
|
||||
:maxlength="2048"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:placeholder="formatMessage(messages.confirmNewPasswordPlaceholder)"
|
||||
autocomplete="current-password"
|
||||
:placeholder="
|
||||
removePasswordMode
|
||||
? formatMessage(messages.confirmPasswordPlaceholder)
|
||||
: formatMessage(messages.oldPasswordPlaceholder)
|
||||
"
|
||||
/>
|
||||
<span v-if="removePasswordMode" class="label__description">{{
|
||||
formatMessage(messages.confirmPasswordDescription)
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<template v-if="!removePasswordMode">
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label for="new-password"
|
||||
><span class="text-md font-semibold text-contrast">{{
|
||||
formatMessage(messages.newPasswordLabel)
|
||||
}}</span></label
|
||||
>
|
||||
<StyledInput
|
||||
id="new-password"
|
||||
v-model="newPassword"
|
||||
:maxlength="2048"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:placeholder="formatMessage(messages.newPasswordPlaceholder)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label for="confirm-new-password">
|
||||
<span class="text-md font-semibold text-contrast">{{
|
||||
formatMessage(messages.confirmNewPasswordLabel)
|
||||
}}</span>
|
||||
</label>
|
||||
<StyledInput
|
||||
id="confirm-new-password"
|
||||
v-model="confirmNewPassword"
|
||||
:maxlength="2048"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:placeholder="formatMessage(messages.confirmNewPasswordPlaceholder)"
|
||||
/>
|
||||
<div
|
||||
v-if="newPassword !== confirmNewPassword && confirmNewPassword.length > 0"
|
||||
class="known-errors"
|
||||
>
|
||||
{{ formatMessage(messages.passwordsDoNotMatchError) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p></p>
|
||||
<div class="input-group push-right">
|
||||
<ButtonStyled>
|
||||
<button @click="$refs.managePasswordModal.hide()">
|
||||
@@ -147,9 +169,10 @@
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
</NewModal>
|
||||
<NewModal
|
||||
ref="manageTwoFactorModal"
|
||||
width="500px"
|
||||
:header="
|
||||
formatMessage(
|
||||
auth.user.has_totp && twoFactorStep === 0
|
||||
@@ -158,10 +181,12 @@
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="universal-modal">
|
||||
<div class="flex flex-col gap-6">
|
||||
<template v-if="auth.user.has_totp && twoFactorStep === 0">
|
||||
<label for="two-factor-code">
|
||||
<span class="label__title">{{ formatMessage(messages.twoFactorEnterCodeLabel) }}</span>
|
||||
<span class="text-md font-semibold text-contrast">{{
|
||||
formatMessage(messages.twoFactorEnterCodeLabel)
|
||||
}}</span>
|
||||
<span class="label__description">{{
|
||||
formatMessage(messages.twoFactorEnterCodeDescription)
|
||||
}}</span>
|
||||
@@ -173,7 +198,7 @@
|
||||
:placeholder="formatMessage(messages.twoFactorCodePlaceholder)"
|
||||
@keyup.enter="removeTwoFactor()"
|
||||
/>
|
||||
<p v-if="twoFactorIncorrect" class="known-errors">
|
||||
<p v-if="twoFactorIncorrect" class="known-errors m-0">
|
||||
{{ formatMessage(messages.twoFactorIncorrectError) }}
|
||||
</p>
|
||||
<div class="input-group push-right">
|
||||
@@ -193,8 +218,8 @@
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="twoFactorStep === 0">
|
||||
<p>{{ formatMessage(messages.twoFactorSetupIntro) }}</p>
|
||||
<p>
|
||||
<p class="m-0">{{ formatMessage(messages.twoFactorSetupIntro) }}</p>
|
||||
<p class="m-0">
|
||||
<IntlFormatted :message-id="messages.twoFactorSetupScan">
|
||||
<template #authy-link="{ children }">
|
||||
<a href="https://authy.com/" target="_blank" rel="noreferrer">
|
||||
@@ -221,14 +246,14 @@
|
||||
:margin="2"
|
||||
level="H"
|
||||
/>
|
||||
<p>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.twoFactorManualSecretPrefix) }}
|
||||
<strong>{{ twoFactorSecret }}</strong>
|
||||
</p>
|
||||
</template>
|
||||
<template v-if="twoFactorStep === 1">
|
||||
<label for="verify-code">
|
||||
<span class="label__title">{{
|
||||
<span class="text-md font-semibold text-contrast">{{
|
||||
formatMessage(messages.twoFactorVerifyCodeLabel)
|
||||
}}</span>
|
||||
<span class="label__description">{{
|
||||
@@ -243,13 +268,13 @@
|
||||
:placeholder="formatMessage(messages.twoFactorCodePlaceholder)"
|
||||
@keyup.enter="verifyTwoFactorCode()"
|
||||
/>
|
||||
<p v-if="twoFactorIncorrect" class="known-errors">
|
||||
<p v-if="twoFactorIncorrect" class="known-errors m-0">
|
||||
{{ formatMessage(messages.twoFactorIncorrectError) }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-if="twoFactorStep === 2">
|
||||
<p>{{ formatMessage(messages.twoFactorBackupCodesIntro) }}</p>
|
||||
<p>{{ formatMessage(messages.twoFactorBackupCodesSingleUse) }}</p>
|
||||
<p class="m-0">{{ formatMessage(messages.twoFactorBackupCodesIntro) }}</p>
|
||||
<p class="m-0">{{ formatMessage(messages.twoFactorBackupCodesSingleUse) }}</p>
|
||||
<ul>
|
||||
<li v-for="code in backupCodes" :key="code">{{ code }}</li>
|
||||
</ul>
|
||||
@@ -282,38 +307,34 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal ref="manageProvidersModal" :header="formatMessage(messages.manageProvidersModalHeader)">
|
||||
<div class="universal-modal">
|
||||
<div class="table">
|
||||
<div class="table-head table-row">
|
||||
<div class="table-text table-cell">
|
||||
{{ formatMessage(messages.providersTableProvider) }}
|
||||
</div>
|
||||
<div class="table-text table-cell">
|
||||
{{ formatMessage(messages.providersTableActions) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="provider in authProviders" :key="provider.id" class="table-row">
|
||||
<div class="table-text table-cell">
|
||||
<span><component :is="provider.icon" /> {{ provider.display }}</span>
|
||||
</div>
|
||||
<div class="table-text manage table-cell">
|
||||
<ButtonStyled v-if="auth.user.auth_providers.includes(provider.id)">
|
||||
<button @click="handleRemoveAuthProvider(provider.id)">
|
||||
<TrashIcon /> {{ formatMessage(commonMessages.removeButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else>
|
||||
<a :href="`${getAuthUrl(provider.id, '/settings/account')}&token=${auth.token}`">
|
||||
<ExternalIcon /> {{ formatMessage(messages.providerAddButton) }}
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p></p>
|
||||
<div class="input-group push-right">
|
||||
</NewModal>
|
||||
<NewModal
|
||||
ref="manageProvidersModal"
|
||||
width="500px"
|
||||
:header="formatMessage(messages.manageProvidersModalHeader)"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Table :columns="manageProvidersColumns" :data="authProviders" row-key="id">
|
||||
<template #cell-provider="{ row }">
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<component :is="row.icon" class="size-5" />
|
||||
{{ row.display }}
|
||||
</span>
|
||||
</template>
|
||||
<template #cell-actions="{ row }">
|
||||
<ButtonStyled v-if="auth.user.auth_providers.includes(row.id)">
|
||||
<button class="!w-full" @click="handleRemoveAuthProvider(row.id)">
|
||||
<TrashIcon /> {{ formatMessage(commonMessages.removeButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else>
|
||||
<a :href="`${getAuthUrl(row.id, '/settings/account')}&token=${auth.token}`">
|
||||
<ExternalIcon /> {{ formatMessage(messages.providerAddButton) }}
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</Table>
|
||||
<div class="self-end">
|
||||
<ButtonStyled>
|
||||
<button @click="$refs.manageProvidersModal.hide()">
|
||||
<XIcon />
|
||||
@@ -322,7 +343,7 @@
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</NewModal>
|
||||
<section class="universal-card">
|
||||
<h2 class="text-2xl">{{ formatMessage(messages.accountSecurityTitle) }}</h2>
|
||||
|
||||
@@ -471,13 +492,16 @@ import {
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
ConfirmModal,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
IntlFormatted,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
Table,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import KeyIcon from 'assets/icons/auth/key.svg'
|
||||
@@ -489,8 +513,7 @@ import MicrosoftIcon from 'assets/icons/auth/sso-microsoft.svg'
|
||||
import SteamIcon from 'assets/icons/auth/sso-steam.svg'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
|
||||
import Modal from '~/components/ui/Modal.vue'
|
||||
import { getAuthUrl, removeAuthProvider } from '~/composables/auth.js'
|
||||
import { getAuthUrl, removeAuthProvider } from '~/composables/auth.ts'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
@@ -957,6 +980,18 @@ const authProviders = [
|
||||
icon: GoogleIcon,
|
||||
},
|
||||
]
|
||||
const manageProvidersColumns = computed(() => [
|
||||
{
|
||||
key: 'provider',
|
||||
label: formatMessage(messages.providersTableProvider),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: formatMessage(messages.providersTableActions),
|
||||
align: 'right',
|
||||
width: '10rem',
|
||||
},
|
||||
])
|
||||
|
||||
async function deleteAccount() {
|
||||
startLoading()
|
||||
@@ -1010,20 +1045,4 @@ 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>
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
<span class="flex items-center gap-2">
|
||||
{{ user.username }}
|
||||
<BadgeCheckIcon
|
||||
v-if="isModrinthUser"
|
||||
v-if="isOfficialAccount"
|
||||
v-tooltip="formatMessage(messages.officialAccount)"
|
||||
class="size-5 text-brand"
|
||||
fill="var(--color-brand-highlight)"
|
||||
@@ -561,7 +561,7 @@ import UpToDate from '~/assets/images/illustrations/up_to_date.svg?component'
|
||||
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
|
||||
import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue'
|
||||
import ModalCreation from '~/components/ui/create/ProjectCreateModal.vue'
|
||||
import { getSignInRouteObj } from '~/composables/auth.js'
|
||||
import { getSignInRouteObj } from '~/composables/auth.ts'
|
||||
import { projectUserSorting } from '~/utils/projects.ts'
|
||||
import { reportUser } from '~/utils/report-helpers.ts'
|
||||
import { hasActiveMidas, hasPride26Badge } from '~/utils/user-membership.ts'
|
||||
@@ -810,6 +810,10 @@ const sortedOrgs = computed(() =>
|
||||
)
|
||||
|
||||
const isModrinthUser = computed(() => user.value?.id === '2REoufqX')
|
||||
const isAutoMod = computed(() => user.value?.id === '')
|
||||
const isOfficialAccount = computed(
|
||||
() => isModrinthUser.value || isAutoMod.value || user.value?.id === 'GVFjtWTf',
|
||||
)
|
||||
|
||||
const sortedCollections = computed(() => {
|
||||
const list = collections.value
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { promotePendingSignInOAuthProvider } from '@/composables/auth.ts'
|
||||
|
||||
export default defineNuxtPlugin(async () => {
|
||||
const auth = await useAuth()
|
||||
if (auth.value.user) {
|
||||
promotePendingSignInOAuthProvider()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import { attributionQuickReplies } from '@modrinth/moderation'
|
||||
import { provideAttributionModeration } from '@modrinth/ui'
|
||||
|
||||
export function setupAttributionModerationProvider() {
|
||||
provideAttributionModeration({ attributionQuickReplies })
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import type { Labrinth } from '@modrinth/api-client'
|
||||
import { type AuthProvider, type AuthUser, provideAuth } from '@modrinth/ui'
|
||||
import { ref, watchEffect } from 'vue'
|
||||
|
||||
import { getSignInRedirectPath } from '~/composables/auth.js'
|
||||
import { getSignInRedirectPath } from '~/composables/auth.ts'
|
||||
|
||||
export function setupAuthProvider(auth: Awaited<ReturnType<typeof useAuth>>) {
|
||||
const router = useRouter()
|
||||
|
||||
@@ -3,9 +3,11 @@ import { SaveIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import {
|
||||
type ComboboxOption,
|
||||
createContext,
|
||||
defineMessage,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
injectProjectPageContext,
|
||||
type MessageDescriptor,
|
||||
type MultiStageModal,
|
||||
resolveCtxFn,
|
||||
type StageButtonConfig,
|
||||
@@ -164,6 +166,44 @@ export const fileTypeLabels: Record<Labrinth.Versions.v3.FileType | 'primary', s
|
||||
signature: 'Signature',
|
||||
}
|
||||
|
||||
export const fileTypeMessages: Record<
|
||||
Labrinth.Versions.v3.FileType | 'primary',
|
||||
MessageDescriptor
|
||||
> = {
|
||||
primary: defineMessage({
|
||||
id: 'version.file-type.primary',
|
||||
defaultMessage: 'Primary',
|
||||
}),
|
||||
unknown: defineMessage({
|
||||
id: 'version.file-type.unknown',
|
||||
defaultMessage: 'Other',
|
||||
}),
|
||||
'required-resource-pack': defineMessage({
|
||||
id: 'version.file-type.required-resource-pack',
|
||||
defaultMessage: 'Required resource pack',
|
||||
}),
|
||||
'optional-resource-pack': defineMessage({
|
||||
id: 'version.file-type.optional-resource-pack',
|
||||
defaultMessage: 'Optional resource pack',
|
||||
}),
|
||||
'sources-jar': defineMessage({
|
||||
id: 'version.file-type.sources-jar',
|
||||
defaultMessage: 'Sources jar',
|
||||
}),
|
||||
'dev-jar': defineMessage({
|
||||
id: 'version.file-type.dev-jar',
|
||||
defaultMessage: 'Dev jar',
|
||||
}),
|
||||
'javadoc-jar': defineMessage({
|
||||
id: 'version.file-type.javadoc-jar',
|
||||
defaultMessage: 'Javadoc jar',
|
||||
}),
|
||||
signature: defineMessage({
|
||||
id: 'version.file-type.signature',
|
||||
defaultMessage: 'Signature file',
|
||||
}),
|
||||
}
|
||||
|
||||
export const [injectManageVersionContext, provideManageVersionContext] =
|
||||
createContext<ManageVersionContextValue>('CreateProjectVersionModal')
|
||||
|
||||
|
||||
+3
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n mel.id,\n mel.title,\n mel.status,\n mel.link,\n mel.exceptions,\n mel.proof,\n mel.flame_project_id,\n mel.inserted_at,\n mel.inserted_by,\n mel.updated_at,\n mel.updated_by\n FROM moderation_external_licenses mel\n WHERE ($1::text IS NULL OR mel.title ILIKE '%' || $1 || '%')\n AND ($2::integer IS NULL OR mel.flame_project_id = $2)\n ORDER BY mel.id\n ",
|
||||
"query": "\n SELECT\n mel.id,\n mel.title,\n mel.status,\n mel.link,\n mel.exceptions,\n mel.proof,\n mel.flame_project_id,\n mel.inserted_at,\n mel.inserted_by,\n mel.updated_at,\n mel.updated_by\n FROM moderation_external_licenses mel\n WHERE mel.flame_project_id = ANY($1)\n ORDER BY mel.id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -61,8 +61,7 @@
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int4"
|
||||
"Int4Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
@@ -79,5 +78,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "6542c79dffa73e462c527f1bd4ba67e658814d7a788259dc4b3874c54cb5ae57"
|
||||
"hash": "03d85f360d4c603688d0662d24caee7b59985adba006fb531beb09f195582277"
|
||||
}
|
||||
Generated
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n select\n fa.file_id as \"file_id: DBFileId\",\n f.url,\n v.mod_id as \"project_id: DBProjectId\"\n from file_scans fa\n inner join files f on f.id = fa.file_id\n inner join attribution_enforced_versions aev on aev.id = f.version_id\n inner join versions v on v.id = f.version_id\n where fa.attributions_scanned_at is null\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "file_id: DBFileId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "url",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "project_id: DBProjectId",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0dda0265b39d4c8b019eb1ea6d164ee639a6deb0f47c1dcdd83a5816308faab0"
|
||||
}
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n mel.id,\n mel.title,\n mel.status,\n mel.link,\n mel.exceptions,\n mel.proof,\n mel.flame_project_id,\n mel.inserted_at,\n mel.inserted_by,\n mel.updated_at,\n mel.updated_by\n FROM moderation_external_files mef\n INNER JOIN moderation_external_licenses mel ON mel.id = mef.external_license_id\n WHERE mef.sha1 = $1\n ",
|
||||
"query": "\n SELECT\n mel.id,\n mel.title,\n mel.status,\n mel.link,\n mel.exceptions,\n mel.proof,\n mel.flame_project_id,\n mel.inserted_at,\n mel.inserted_by,\n mel.updated_at,\n mel.updated_by\n FROM moderation_external_licenses mel\n WHERE ($1::text IS NULL OR mel.title ILIKE '%' || $1 || '%')\n AND (\n ($2::integer IS NULL AND $3::integer[] IS NULL)\n OR mel.flame_project_id = $2\n OR mel.flame_project_id = ANY($3)\n )\n ORDER BY mel.id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -61,7 +61,9 @@
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Bytea"
|
||||
"Text",
|
||||
"Int4",
|
||||
"Int4Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
@@ -78,5 +80,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "99749414f92886a904158484661cae8928f78206c1cdf8adb66fde999bbe94d4"
|
||||
"hash": "0e9e528a008a9dd24acfabbffcfe8ee4fd48fbae7c6197bfbbf1923e6256658b"
|
||||
}
|
||||
+12
-6
@@ -1,30 +1,35 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT DISTINCT dependent_id as version_id, d.mod_dependency_id as dependency_project_id, d.dependency_id as dependency_version_id, d.dependency_file_name as file_name, d.dependency_type as dependency_type\n FROM dependencies d\n WHERE dependent_id = ANY($1)\n ",
|
||||
"query": "\n SELECT DISTINCT d.id as dependency_id, dependent_id as version_id, d.mod_dependency_id as dependency_project_id, d.dependency_id as dependency_version_id, d.dependency_file_name as file_name, d.dependency_type as dependency_type\n FROM dependencies d\n WHERE dependent_id = ANY($1)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "dependency_id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "version_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"ordinal": 2,
|
||||
"name": "dependency_project_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"ordinal": 3,
|
||||
"name": "dependency_version_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"ordinal": 4,
|
||||
"name": "file_name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"ordinal": 5,
|
||||
"name": "dependency_type",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
@@ -35,6 +40,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
@@ -42,5 +48,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "623881c24c12e77f6fc57669929be55a34800cd2269da29d555959164919c9a3"
|
||||
"hash": "16e13baf35118cda943abf259a0e24cfe13436844b7909e6502ec15a249fc856"
|
||||
}
|
||||
Generated
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into project_attribution_files (group_id, name, sha1, moderation_external_license_id)\n values ($1, $2, $3, $4)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text",
|
||||
"Bytea",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1d27a83fb85c4640c3fc88fc6caa8209973ced0f88d8dbecdac349bbe70930a5"
|
||||
}
|
||||
Generated
+46
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tselect\n\t\t\tg.id as \"id: DBAttributionGroupId\",\n\t\t\tg.flame_project,\n\t\t\tg.attribution,\n\t\t\tg.attributed_at,\n\t\t\tg.attributed_by as \"attributed_by: i64\"\n\t\tfrom project_attribution_groups g\n\t\twhere g.project_id = $1\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id: DBAttributionGroupId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "flame_project",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "attribution",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "attributed_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "attributed_by: i64",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "1d96df0ac64801641f9af796d482d2964c1acf2d03e15b8f1397340d0c994af6"
|
||||
}
|
||||
Generated
+88
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n mef.sha1 hash,\n mel.id,\n mel.title,\n mel.status,\n mel.link,\n mel.exceptions,\n mel.proof,\n mel.flame_project_id,\n mel.inserted_at,\n mel.inserted_by,\n mel.updated_at,\n mel.updated_by\n FROM moderation_external_files mef\n INNER JOIN moderation_external_licenses mel ON mel.id = mef.external_license_id\n WHERE mef.sha1 = ANY($1)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "hash",
|
||||
"type_info": "Bytea"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "title",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "link",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "exceptions",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "proof",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "flame_project_id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "inserted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "inserted_by",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "updated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "updated_by",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"ByteaArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "25d6977fa2db63f979fbe391f3475445fa92c9bbb5b034531f5e033a18c2a6a3"
|
||||
}
|
||||
Generated
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tselect attribution, project_id as \"project_id: DBProjectId\"\n\t\tfrom project_attribution_groups\n\t\twhere id = $1\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "attribution",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "project_id: DBProjectId",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "262070fb8ef00ba6e7c7e7b3b5262e959448892945bf56117ba74446808cea76"
|
||||
}
|
||||
Generated
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into project_attribution_files (group_id, name, sha1)\n select $1, unnest($2::text[]), unnest($3::bytea[])\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"TextArray",
|
||||
"ByteaArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2e6bb84f45c0f8ba7bb9b09bf3def4322f727c7af8bd4be49dc4d7c487b925ed"
|
||||
}
|
||||
Generated
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT mel.id, mel.flame_project_id, mel.status status, mel.link\n FROM moderation_external_licenses mel\n WHERE mel.flame_project_id = ANY($1)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "flame_project_id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "link",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "2f56a06b78a810936a77547ab5890943d6aa3e9143a8e2617d025be960880223"
|
||||
}
|
||||
Generated
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n DELETE FROM project_attribution_groups g\n WHERE NOT EXISTS (\n SELECT 1\n FROM project_attribution_files paf\n INNER JOIN override_file_sources ofs ON ofs.sha1 = paf.sha1\n WHERE paf.group_id = g.id\n )\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "301959b1ec20a4925f86188bc5e1c0cdd11f0d15004ce9f91fbeefcc3a27f8f4"
|
||||
}
|
||||
+9
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n created, role, badges,\n (\n SELECT MAX(campaign_donations.donated_at)\n FROM campaign_donations\n WHERE campaign_donations.user_id = users.id\n ) AS campaign_pride_26_last_donated_at,\n (\n SELECT SUM(campaign_donations.amount_usd)\n FROM campaign_donations\n WHERE campaign_donations.user_id = users.id\n ) AS campaign_pride_26_total_amount_donated_usd,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ",
|
||||
"query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n created, role, badges,\n (\n SELECT MAX(campaign_donations.donated_at)\n FROM campaign_donations\n WHERE campaign_donations.user_id = users.id\n ) AS campaign_pride_26_last_donated_at,\n (\n SELECT SUM(campaign_donations.amount_usd)\n FROM campaign_donations\n WHERE campaign_donations.user_id = users.id\n ) AS campaign_pride_26_total_amount_donated_usd,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter,\n eligibility_verified_at\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -137,6 +137,11 @@
|
||||
"ordinal": 26,
|
||||
"name": "is_subscribed_to_newsletter",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 27,
|
||||
"name": "eligibility_verified_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -172,8 +177,9 @@
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "e6c22fe10d603206c8466da630b30d0d4848455f5cddbf9202d9cdbfa1f306b5"
|
||||
"hash": "3ca51012492b969bb6a474ee22c9e53e57b6da3a1145f0d86a2bab36be436eb6"
|
||||
}
|
||||
Generated
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n select id as \"id: DBAttributionGroupId\", flame_project\n from project_attribution_groups\n where project_id = $1 and flame_project is not null\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id: DBAttributionGroupId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "flame_project",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "424b975139004d2854d9365ef950984dd1a65e333bb80924b5883df5e7d3cad2"
|
||||
}
|
||||
Generated
+47
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\t\tselect\n\t\t\t\tpaf.group_id as \"group_id!\",\n\t\t\t\tpaf.name as \"name!\",\n\t\t\t\tconvert_from(paf.sha1, 'UTF8') as \"sha1!\",\n\t\t\t\tpaf.moderation_external_license_id,\n\t\t\t\tcoalesce(array_agg(distinct aev.id) filter (where aev.id is not null), '{}') as \"version_ids!: Vec<i64>\"\n\t\t\tfrom project_attribution_files paf\n\t\t\tleft join override_file_sources ofs on ofs.sha1 = paf.sha1\n\t\t\tleft join files f on f.id = ofs.file_id\n\t\t\tleft join versions v on v.id = f.version_id and v.mod_id = $2\n\t\t\tleft join attribution_enforced_versions aev on aev.id = v.id\n\t\t\twhere paf.group_id = ANY($1)\n\t\t\tgroup by paf.group_id, paf.name, paf.sha1, paf.moderation_external_license_id\n\t\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "group_id!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "sha1!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "moderation_external_license_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "version_ids!: Vec<i64>",
|
||||
"type_info": "Int8Array"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
true,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "48a83bc9de9bea73eac5c43e71f43fb8a1ac7eb67079d16e3de92f5ff6bf72b8"
|
||||
}
|
||||
Generated
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tupdate project_attribution_files\n\t\tset group_id = $1\n\t\twhere sha1 = $2\n\t\tand group_id in (\n\t\t\tselect id from project_attribution_groups where project_id = $3\n\t\t)\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Bytea",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4fb69c674dca723b6b2cb7bea07d51feb43b2714c1cd6a2987a6fe1f10e2579a"
|
||||
}
|
||||
Generated
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tupdate project_attribution_files\n\t\tset group_id = $1\n\t\twhere sha1 = $2 and group_id = $3\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Bytea",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "5f7b9d628d3ff0addc12cfaab68d7cbcca08a89aedeb15ce5926ed87ed8a12c6"
|
||||
}
|
||||
Generated
+82
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n id,\n title,\n status,\n link,\n exceptions,\n proof,\n flame_project_id,\n inserted_at,\n inserted_by,\n updated_at,\n updated_by\n FROM moderation_external_licenses\n WHERE id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "title",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "link",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "exceptions",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "proof",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "flame_project_id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "inserted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "inserted_by",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "updated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "updated_by",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "5f847946ce63d3773b9a5822971ee7acaafeed018ce0540b2826bd878f213b87"
|
||||
}
|
||||
Generated
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tinsert into project_attribution_groups (id, project_id)\n\t\tvalues ($1, $2)\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6320df0491556027ee64fd031797b252c2145ecf179e9ea9a8cedbdc8b7ed622"
|
||||
}
|
||||
Generated
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tselect exists(\n\t\t\tselect 1 from project_attribution_groups where id = $1 and project_id = $2\n\t\t) as \"exists!\"\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "73f7542b4b6738c4baf1e2a3513645c3bf07a960b7b9fac529f23099675ec0cc"
|
||||
}
|
||||
Generated
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into file_scans (file_id, attributions_scanned_at)\n values ($1, now())\n on conflict (file_id) do update set attributions_scanned_at = now()\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "80751dbca09a9dcb469a30fe5b8225e520baf50750a78875f582349610439a55"
|
||||
}
|
||||
Generated
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n select distinct f.version_id as \"version_id: DBVersionId\", f.id as \"file_id: DBFileId\",\n pag.flame_project\n from files f\n inner join attribution_enforced_versions aev on aev.id = f.version_id\n inner join versions v on v.id = f.version_id\n inner join override_file_sources ofs on ofs.file_id = f.id\n inner join project_attribution_files paf on paf.sha1 = ofs.sha1\n inner join project_attribution_groups pag on pag.id = paf.group_id\n where f.version_id = ANY($1)\n and pag.project_id = v.mod_id\n and (\n pag.attribution is null\n or pag.attribution->>'kind' = 'no_permission'\n or coalesce(\n pag.attribution->'moderation_status'->>'kind',\n 'approved'\n ) != 'approved'\n )\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "version_id: DBVersionId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "file_id: DBFileId",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "flame_project",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "86e59f16918c0197b4e1492d3f72e191a8deb05f7753743f48f1b6647f83f544"
|
||||
}
|
||||
Generated
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT DISTINCT ON (dr.id)\n to_jsonb(dr)\n || jsonb_build_object(\n 'report_id', dr.id,\n 'file_id', to_base62(f.id),\n 'version_id', to_base62(v.id),\n 'project_id', to_base62(v.mod_id),\n 'file_name', f.filename,\n 'file_size', f.size,\n 'flag_reason', 'delphi',\n 'download_url', f.url,\n -- TODO: replace with `json_array` in Postgres 16\n 'issues', (\n SELECT json_agg(\n to_jsonb(dri)\n || jsonb_build_object(\n -- TODO: replace with `json_array` in Postgres 16\n 'details', (\n SELECT coalesce(jsonb_agg(\n jsonb_build_object(\n 'id', didws.id,\n 'issue_id', didws.issue_id,\n 'key', didws.key,\n 'file_path', didws.file_path,\n 'decompiled_source', didws.decompiled_source,\n 'data', didws.data,\n 'severity', didws.severity,\n 'status', didws.status\n )\n ), '[]'::jsonb)\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.issue_id = dri.id\n )\n )\n )\n FROM delphi_report_issues dri\n WHERE\n dri.report_id = dr.id\n -- see delphi.rs todo comment\n AND dri.issue_type != '__dummy'\n )\n ) AS \"data!: sqlx::types::Json<FileReport>\"\n FROM delphi_reports dr\n INNER JOIN files f ON f.id = dr.file_id\n INNER JOIN versions v ON v.id = f.version_id\n WHERE dr.id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "data!: sqlx::types::Json<FileReport>",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "8820a5985291c159c98371c9650092e3eba21c81e3b3386be779978aff30451a"
|
||||
}
|
||||
Generated
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM project_attribution_groups WHERE id=$1)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "8ffcb0c2bd82eaf64b143745adab359db6fcc448d6306292a7b345277a075883"
|
||||
}
|
||||
Generated
+82
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\t\t\tselect\n\t\t\t\t\tid,\n\t\t\t\t\ttitle,\n\t\t\t\t\tstatus,\n\t\t\t\t\tlink,\n\t\t\t\t\texceptions,\n\t\t\t\t\tproof,\n\t\t\t\t\tflame_project_id,\n\t\t\t\t\tinserted_at,\n\t\t\t\t\tinserted_by,\n\t\t\t\t\tupdated_at,\n\t\t\t\t\tupdated_by\n\t\t\t\tfrom moderation_external_licenses\n\t\t\t\twhere id = ANY($1)\n\t\t\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "title",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "link",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "exceptions",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "proof",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "flame_project_id",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "inserted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "inserted_by",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "updated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "updated_by",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "944286604bdef4ee40f79af358d1b68cc93bfd2067619d9cc1d03013f73e5424"
|
||||
}
|
||||
Generated
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tupdate project_attribution_groups\n\t\tset attribution = $1, attributed_at = now(), attributed_by = $3\n\t\twhere id = $2\n\t\t",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Jsonb",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "94920d66b27503c84744d41cb2b44dd213bb461596046488eaab0b62b0fe83c5"
|
||||
}
|
||||
Generated
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into project_attribution_files (group_id, name, sha1, moderation_external_license_id)\n values ($1, $2, $3, $4)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text",
|
||||
"Bytea",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "95750cd616b72347fb997c3b02a3d69f13be5993c1fd2e302761089c499fb015"
|
||||
}
|
||||
Generated
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n update file_scans\n set attributions_scanned_at = now\n from unnest($1::bigint[], $2::timestamptz[]) as u(id, now)\n where file_scans.file_id = u.id\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array",
|
||||
"TimestamptzArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "968904f577c2c696c6222e19cc145bce0e845f2ab9f8629b0789a4861bddb4dc"
|
||||
}
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO users (\n id, username, email,\n avatar_url, raw_avatar_url, bio, created,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7,\n $8, $9, $10, $11, $12, $13,\n $14, $15, $16, $17, $18, $19, $20, $21, $22\n )\n ",
|
||||
"query": "\n INSERT INTO users (\n id, username, email,\n avatar_url, raw_avatar_url, bio, created,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter,\n eligibility_verified_at\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7,\n $8, $9, $10, $11, $12, $13,\n $14, $15, $16, $17, $18, $19, $20, $21, $22,\n $23\n )\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -26,10 +26,11 @@
|
||||
"Text",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Bool"
|
||||
"Bool",
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "010c69fa61e1329156020b251e75d46bc09344c1846b3098accce5801e571e5e"
|
||||
"hash": "a335ff4338fa4b3c1aaa9f62c61aad3c3e8bef0e68035a024995c62cfe69c568"
|
||||
}
|
||||
Generated
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n select\n d.id as \"dependency_id!\",\n pag.attribution,\n pag.flame_project,\n pag.project_id as \"project_id: DBProjectId\"\n from dependencies d\n inner join files f on f.version_id = d.dependent_id\n inner join attribution_enforced_versions aev on aev.id = f.version_id\n inner join versions v on v.id = f.version_id\n inner join override_file_sources ofs on ofs.file_id = f.id\n inner join project_attribution_files paf on paf.sha1 = ofs.sha1\n inner join project_attribution_groups pag on pag.id = paf.group_id\n where d.dependent_id = ANY($1)\n and pag.project_id = v.mod_id\n and d.dependency_file_name is not null\n and (\n pag.flame_project is not null\n or pag.attribution is not null\n )\n and split_part(paf.name, '/', -1) = d.dependency_file_name\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "dependency_id!",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "attribution",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "flame_project",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "project_id: DBProjectId",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "adef48e6edd40766c05946e1cb71bc6c7700c31726c4f4f39a1bdcdc4f84e1fd"
|
||||
}
|
||||
Generated
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into project_attribution_groups (id, project_id)\n values ($1, $2)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b471add519874a6364b7a9cdbfa3a8e6f5a8a1ac9a7bacbce378f7995eb48664"
|
||||
}
|
||||
Generated
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tselect paf.group_id\n\t\tfrom project_attribution_files paf\n\t\tinner join project_attribution_groups pag on pag.id = paf.group_id\n\t\twhere paf.sha1 = $1 and pag.project_id = $2\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "group_id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Bytea",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b782ed64e0df1e83623c8a8e55c00716f5338c85b6dfbb15c3465ff0cec339a7"
|
||||
}
|
||||
Generated
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into project_attribution_files (group_id, name, sha1)\n values ($1, $2, $3)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text",
|
||||
"Bytea"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c39e1f0d820101ac1b19a3849254b03259f38f3de9ddca32e6189a7e015dd853"
|
||||
}
|
||||
Generated
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO file_scans (file_id)\n SELECT $1\n WHERE EXISTS (\n SELECT 1\n FROM attribution_enforced_versions\n WHERE id = $2\n )\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "cd29f5f072e102f9286869081b387eca5cee6b78f175320cbd615ce216e39171"
|
||||
}
|
||||
Generated
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\tselect paf.group_id, paf.name from project_attribution_files paf\n\t\tinner join project_attribution_groups pag on pag.id = paf.group_id\n\t\twhere paf.sha1 = $1 and pag.project_id = $2\n\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "group_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Bytea",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ce9b408d416b4782ad501e14cb1ff006964818e81855bb299b269ffaa4c3f7fa"
|
||||
}
|
||||
Generated
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n\t\t\tselect id, name, version_number, date_published\n\t\t\tfrom versions\n\t\t\twhere id = ANY($1)\n\t\t\torder by date_published desc\n\t\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "version_number",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "date_published",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "dac21bfc23fe361a6133e69892224351aefa03f14efec1be31a21441419e19b9"
|
||||
}
|
||||
Generated
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into project_attribution_groups (id, project_id, attribution, flame_project)\n values ($1, $2, $3, $4)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Jsonb",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "df7652db8624e291447975b1d9b2151b1627316fbaaea4914607d66869670375"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user