diff --git a/composables/auth.js b/composables/auth.js
index e58c44f6b..29080edf8 100644
--- a/composables/auth.js
+++ b/composables/auth.js
@@ -92,9 +92,14 @@ export const initAuth = async (oldToken = null) => {
return auth
}
-export const getAuthUrl = (provider) => {
+export const getAuthUrl = (provider, redirect = '') => {
const config = useRuntimeConfig()
const route = useRoute()
- return `${config.public.apiBaseUrl}auth/init?url=${config.public.siteUrl}${route.path}&provider=${provider}`
+ if (redirect === '') {
+ redirect = route.path
+ }
+ const fullURL = `${config.public.siteUrl}${redirect}`
+
+ return `${config.public.apiBaseUrl}auth/init?url=${fullURL}&provider=${provider}`
}
diff --git a/composables/fetch.js b/composables/fetch.js
index b296e529a..be6920830 100644
--- a/composables/fetch.js
+++ b/composables/fetch.js
@@ -1,6 +1,6 @@
export const useBaseFetch = async (url, options = {}, skipAuth = false) => {
const config = useRuntimeConfig()
- const base = process.server ? config.apiBaseUrl : config.public.apiBaseUrl
+ let base = process.server ? config.apiBaseUrl : config.public.apiBaseUrl
if (!options.headers) {
options.headers = {}
@@ -16,5 +16,21 @@ export const useBaseFetch = async (url, options = {}, skipAuth = false) => {
options.headers.Authorization = auth.value.token
}
+ if (options.apiVersion || options.internal) {
+ // Base may end in /vD/ or /vD. We would need to replace the digit with the new version number
+ // and keep the trailing slash if it exists
+ const baseVersion = base.match(/\/v\d\//)
+
+ const replaceStr = options.internal ? `/_internal/` : `/v${options.apiVersion}/`
+
+ if (baseVersion) {
+ base = base.replace(baseVersion[0], replaceStr)
+ } else {
+ base = base.replace(/\/v\d$/, replaceStr)
+ }
+
+ delete options.apiVersion
+ }
+
return await $fetch(`${base}${url}`, options)
}
diff --git a/middleware/auth.js b/middleware/auth.js
index 34061c991..870155375 100644
--- a/middleware/auth.js
+++ b/middleware/auth.js
@@ -1,7 +1,35 @@
+const whitelistedParams = ['flow', 'error']
+
export default defineNuxtRouteMiddleware(async (_to, from) => {
+ const config = useRuntimeConfig()
const auth = await useAuth()
if (!auth.value.user) {
- return navigateTo(`/auth/sign-in?redirect=${encodeURIComponent(from.fullPath)}`)
+ const fullPath = from.fullPath
+
+ const url = new URL(fullPath, config.public.apiBaseUrl)
+
+ const extractedParams = whitelistedParams.reduce((acc, param) => {
+ if (url.searchParams.has(param)) {
+ acc[param] = url.searchParams.get(param)
+ url.searchParams.delete(param)
+ }
+ return acc
+ }, {})
+
+ const redirectPath = encodeURIComponent(url.pathname + url.search)
+
+ return await navigateTo(
+ {
+ path: '/auth/sign-in',
+ query: {
+ redirect: redirectPath,
+ ...extractedParams,
+ },
+ },
+ {
+ replace: true,
+ }
+ )
}
})
diff --git a/pages/auth/authorize.vue b/pages/auth/authorize.vue
new file mode 100644
index 000000000..42325f351
--- /dev/null
+++ b/pages/auth/authorize.vue
@@ -0,0 +1,312 @@
+
+
+
+
+
Error
+
+
+ {{ error.data.error }}:
+ {{ error.data.description }}
+
+
+
+
+
+
Authorize {{ app.name }}
+
+
+
+ {{ app.name }} by
+ {{
+ createdBy.username
+ }}
+ will be able to:
+
+
+
+
+
+
+
+ {{ scopeItem }}
+
+
+
+
+
+
+
+ Decline
+
+
+
+ Authorize
+
+
+
+
+ You will be redirected to
+ {{ redirectUri }}
+
+
+
+
+
+
+
+
+
diff --git a/pages/auth/sign-in.vue b/pages/auth/sign-in.vue
index 5b7cea65c..064768fb6 100644
--- a/pages/auth/sign-in.vue
+++ b/pages/auth/sign-in.vue
@@ -22,27 +22,27 @@
Sign in with
-
+
Discord
-
+
GitHub
-
+
Microsoft
-
+
Google
-
+
Steam
-
+
GitLab
@@ -111,6 +111,8 @@ useHead({
const auth = await useAuth()
const route = useRoute()
+const redirectTarget = route.query.redirect || ''
+
if (route.fullPath.includes('new_account=true')) {
await navigateTo(
`/auth/welcome?authToken=${route.query.code}${
@@ -122,7 +124,7 @@ if (route.fullPath.includes('new_account=true')) {
}
if (auth.value.user) {
- await navigateTo('/dashboard')
+ await finishSignIn()
}
const turnstile = ref()
@@ -190,6 +192,7 @@ async function begin2FASignIn() {
}
stopLoading()
}
+
async function finishSignIn(token) {
if (token) {
await useAuth(token)
@@ -197,7 +200,10 @@ async function finishSignIn(token) {
}
if (route.query.redirect) {
- await navigateTo(route.query.redirect)
+ const redirect = decodeURIComponent(route.query.redirect)
+ await navigateTo(redirect, {
+ replace: true,
+ })
} else {
await navigateTo('/dashboard')
}
diff --git a/pages/auth/sign-up.vue b/pages/auth/sign-up.vue
index 32574b43e..44d644744 100644
--- a/pages/auth/sign-up.vue
+++ b/pages/auth/sign-up.vue
@@ -3,27 +3,27 @@
Sign up with
-
+
Discord
-
+
GitHub
-
+
Microsoft
-
+
Google
-
+
Steam
-
+
GitLab
@@ -129,6 +129,8 @@ useHead({
const auth = await useAuth()
const route = useRoute()
+const redirectTarget = route.query.redirect
+
if (route.fullPath.includes('new_account=true')) {
await navigateTo(
`/auth/welcome?authToken=${route.query.code}${
diff --git a/pages/settings.vue b/pages/settings.vue
index 71c70cf39..294c11acb 100644
--- a/pages/settings.vue
+++ b/pages/settings.vue
@@ -15,8 +15,8 @@
-
-
+
+
@@ -25,6 +25,15 @@
+
+ Developer Settings
+
+
+
+
+
+
+
@@ -34,6 +43,7 @@
+
diff --git a/pages/settings/authorizations.vue b/pages/settings/authorizations.vue
new file mode 100644
index 000000000..c1c76e7a8
--- /dev/null
+++ b/pages/settings/authorizations.vue
@@ -0,0 +1,245 @@
+
+
+
+
Authorizations
+
+ When you authorize an application with your Modrinth account, you grant it access to your
+ account. You can manage and review access to your account here at any time.
+
+
+ You have not authorized any applications.
+
+
+
+
+
+
+
+
+ {{ authorization.app.name }}
+
+
+ by
+ {{
+ authorization.owner.username
+ }}
+
+ ⋅
+
+ {{ authorization.app.url }}
+
+
+
+
+
+
+
+
+
+ About this app
+
+ {{ authorization.app.description }}
+
+
+
+ Scopes
+
+
+
+
+
+
+ {{ constCaseToTitleCase(scope) }}
+
+
+
+
+
+
+ {
+ revokingId = authorization.app_id
+ $refs.modal_confirm.show()
+ }
+ "
+ >
+
+ Revoke
+
+
+
+
+
+
+
+
diff --git a/utils/auth/scopes.ts b/utils/auth/scopes.ts
new file mode 100644
index 000000000..d0f8887fb
--- /dev/null
+++ b/utils/auth/scopes.ts
@@ -0,0 +1,157 @@
+export const Scopes = {
+ USER_READ_EMAIL: BigInt(1) << BigInt(0),
+ USER_READ: BigInt(1) << BigInt(1),
+ USER_WRITE: BigInt(1) << BigInt(2),
+ USER_DELETE: BigInt(1) << BigInt(3),
+ USER_AUTH_WRITE: BigInt(1) << BigInt(4),
+ NOTIFICATION_READ: BigInt(1) << BigInt(5),
+ NOTIFICATION_WRITE: BigInt(1) << BigInt(6),
+ PAYOUTS_READ: BigInt(1) << BigInt(7),
+ PAYOUTS_WRITE: BigInt(1) << BigInt(8),
+ ANALYTICS: BigInt(1) << BigInt(9),
+ PROJECT_CREATE: BigInt(1) << BigInt(10),
+ PROJECT_READ: BigInt(1) << BigInt(11),
+ PROJECT_WRITE: BigInt(1) << BigInt(12),
+ PROJECT_DELETE: BigInt(1) << BigInt(13),
+ VERSION_CREATE: BigInt(1) << BigInt(14),
+ VERSION_READ: BigInt(1) << BigInt(15),
+ VERSION_WRITE: BigInt(1) << BigInt(16),
+ VERSION_DELETE: BigInt(1) << BigInt(17),
+ REPORT_CREATE: BigInt(1) << BigInt(18),
+ REPORT_READ: BigInt(1) << BigInt(19),
+ REPORT_WRITE: BigInt(1) << BigInt(20),
+ REPORT_DELETE: BigInt(1) << BigInt(21),
+ THREAD_READ: BigInt(1) << BigInt(22),
+ THREAD_WRITE: BigInt(1) << BigInt(23),
+ PAT_CREATE: BigInt(1) << BigInt(24),
+ PAT_READ: BigInt(1) << BigInt(25),
+ PAT_WRITE: BigInt(1) << BigInt(26),
+ PAT_DELETE: BigInt(1) << BigInt(27),
+ SESSION_READ: BigInt(1) << BigInt(28),
+ SESSION_DELETE: BigInt(1) << BigInt(29),
+ PERFORM_ANALYTICS: BigInt(1) << BigInt(30),
+ COLLECTION_CREATE: BigInt(1) << BigInt(31),
+ COLLECTION_READ: BigInt(1) << BigInt(32),
+ COLLECTION_WRITE: BigInt(1) << BigInt(33),
+ COLLECTION_DELETE: BigInt(1) << BigInt(34),
+ ORGANIZATION_CREATE: BigInt(1) << BigInt(35),
+ ORGANIZATION_READ: BigInt(1) << BigInt(36),
+ ORGANIZATION_WRITE: BigInt(1) << BigInt(37),
+ ORGANIZATION_DELETE: BigInt(1) << BigInt(38),
+ SESSION_ACCESS: BigInt(1) << BigInt(39),
+}
+
+export const restrictedScopes = [
+ Scopes.PAT_READ,
+ Scopes.PAT_CREATE,
+ Scopes.PAT_WRITE,
+ Scopes.PAT_DELETE,
+ Scopes.SESSION_READ,
+ Scopes.SESSION_DELETE,
+ Scopes.SESSION_ACCESS,
+ Scopes.USER_AUTH_WRITE,
+ Scopes.USER_DELETE,
+ Scopes.PERFORM_ANALYTICS,
+]
+
+export const scopeList = Object.entries(Scopes)
+ .filter(([_, value]) => !restrictedScopes.includes(value))
+ .map(([key, _]) => key)
+
+export const encodeScopes = (scopes: string[]) => {
+ let scopeFlag = BigInt(0)
+
+ // We iterate over the provided scopes
+ for (const scope of scopes) {
+ // We iterate over the entries of the Scopes object
+ for (const [scopeName, scopeFlagValue] of Object.entries(Scopes)) {
+ // If the scope name is the same as the provided scope, add the scope flag to the scopeFlag variable
+ if (scopeName === scope) {
+ scopeFlag = scopeFlag | scopeFlagValue
+ }
+ }
+ }
+
+ return scopeFlag
+}
+
+export const decodeScopes = (scopes: bigint | number) => {
+ if (typeof scopes === 'number') {
+ scopes = BigInt(scopes)
+ }
+
+ const authorizedScopes = []
+
+ // We iterate over the entries of the Scopes object
+ for (const [scopeName, scopeFlag] of Object.entries(Scopes)) {
+ // If the scope flag is present in the provided number, add the scope name to the list
+ if ((scopes & scopeFlag) === scopeFlag) {
+ authorizedScopes.push(scopeName)
+ }
+ }
+
+ return authorizedScopes
+}
+
+export const hasScope = (scopes: bigint, scope: string) => {
+ const authorizedScopes = decodeScopes(scopes)
+ return authorizedScopes.includes(scope)
+}
+
+export const toggleScope = (scopes: bigint, scope: string) => {
+ const authorizedScopes = decodeScopes(scopes)
+ if (authorizedScopes.includes(scope)) {
+ return encodeScopes(authorizedScopes.filter((authorizedScope) => authorizedScope !== scope))
+ } else {
+ return encodeScopes([...authorizedScopes, scope])
+ }
+}
+
+export const getScopeDefinitions = (scopes: bigint) => {
+ return decodeScopes(scopes)
+ .filter((scope) => Object.keys(ScopeDescriptions).includes(scope))
+ .map((scope) => (ScopeDescriptions as Record)[scope])
+}
+
+export const ScopeDescriptions = {
+ USER_READ_EMAIL: 'Read your email',
+ USER_READ: 'Access your public profile information',
+ USER_WRITE: 'Write to your profile',
+ USER_DELETE: 'Delete your account',
+ USER_AUTH_WRITE: 'Modify your authentication data',
+ NOTIFICATION_READ: 'Read your notifications',
+ NOTIFICATION_WRITE: 'Delete/View your notifications',
+ PAYOUTS_READ: 'Read your payouts data',
+ PAYOUTS_WRITE: 'Withdraw money',
+ ANALYTICS: 'Access your analytics data',
+ PROJECT_CREATE: 'Create new projects',
+ PROJECT_READ: 'Read all your projects',
+ PROJECT_WRITE: 'Write to project data',
+ PROJECT_DELETE: 'Delete your projects',
+ VERSION_CREATE: 'Create new versions',
+ VERSION_READ: 'Read all versions',
+ VERSION_WRITE: 'Write to version data',
+ VERSION_DELETE: 'Delete a version',
+ REPORT_CREATE: 'Create reports',
+ REPORT_READ: 'Read reports',
+ REPORT_WRITE: 'Edit reports',
+ REPORT_DELETE: 'Delete reports',
+ THREAD_READ: 'Read threads',
+ THREAD_WRITE: 'Write to threads',
+ PAT_CREATE: 'Create personal API tokens',
+ PAT_READ: 'View created API tokens',
+ PAT_WRITE: 'Edit personal API tokens',
+ PAT_DELETE: 'Delete your personal API tokens',
+ SESSION_READ: 'Read active sessions',
+ SESSION_DELETE: 'Delete sessions',
+ PERFORM_ANALYTICS: 'Perform analytics actions',
+ COLLECTION_CREATE: 'Create collections',
+ COLLECTION_READ: 'Read collections',
+ COLLECTION_WRITE: 'Write to collections',
+ COLLECTION_DELETE: 'Delete collections',
+ ORGANIZATION_CREATE: 'Create organizations',
+ ORGANIZATION_READ: 'Read organizations',
+ ORGANIZATION_WRITE: 'Write to organizations',
+ ORGANIZATION_DELETE: 'Delete organizations',
+ SESSION_ACCESS: 'Access modrinth-issued sessions',
+}