Compare commits

...
Author SHA1 Message Date
aecsocket 2643e6ef1a Merge branch 'boris/dev-886-modpack-project' into tmp-staging-modpack-oauth 2026-06-23 16:01:31 +01:00
aecsocket 8215129e55 fix 2026-06-23 00:18:48 +01:00
Prospector 21fdd30919 QA Jun 22 2026-06-22 15:58:51 -07:00
aecsocket 7fcf5b8c82 add more logging 2026-06-22 23:41:07 +01:00
aecsocket 2f607774e7 change what files get cscanned 2026-06-22 23:19:16 +01:00
aecsocket a713583b83 prepare 2026-06-22 20:06:47 +01:00
aecsocket 4d736e71d3 Merge branch 'boris/dev-886-modpack-project' into tmp-staging-modpack-oauth 2026-06-22 19:55:38 +01:00
aecsocket f6f2cb8d8b Merge branch 'main' into boris/dev-908-backend-changes 2026-06-22 19:50:47 +01:00
aecsocket 079381246b Fix permission error 2026-06-22 19:37:40 +01:00
aecsocket fadd3cf7f8 fix cross project attribution stuff 2026-06-22 18:19:49 +01:00
aecsocket b24a45ba8c fix ci 2026-06-22 18:10:54 +01:00
aecsocket 4b2b5a56c1 Ignore Flame projects which 404 2026-06-22 17:59:22 +01:00
aecsocket 5aafa40f31 allow exempting projects 2026-06-22 17:30:19 +01:00
aecsocket 5b80b71be4 Merge branch 'main' into boris/dev-886-modpack-project 2026-06-22 17:07:32 +01:00
Prospector f9f569bec6 QA 2026-06-20 12:08:46 -07:00
Prospector 1943fe4f80 Merge branch 'main' into boris/dev-886-modpack-project 2026-06-19 12:44:30 -07:00
Prospector 992c206200 Merge branch 'main' into boris/dev-886-modpack-project 2026-06-12 07:53:29 -07:00
aecsocket 1a0633b6db Merge branch 'main' into boris/dev-908-backend-changes 2026-06-11 21:31:23 +01:00
aecsocket 2b38ea6076 prepare 2026-06-11 21:30:34 +01:00
aecsocket 2a873e4500 Merge branch 'main' into boris/dev-886-modpack-project 2026-06-11 21:27:34 +01:00
aecsocket 3ae50a2abb Merge branch 'main' into boris/dev-908-backend-changes 2026-06-11 20:09:58 +01:00
tdgao 408927a23e Merge branch 'main' into boris/dev-908-backend-changes 2026-06-10 11:58:27 -07:00
coolbot100s b2408eec0d Quick replies for external content permissions. 2026-06-08 18:36:19 -07:00
coolbot100s 895e7aabc6 Reorganize moderation messages. 2026-06-08 17:23:11 -07:00
Prospector 3970b4e3ae move supplementary resources 2026-06-08 15:20:48 -07:00
aecsocket 722e35fe28 don't redact moderated_at 2026-06-08 17:40:06 +01:00
Prospector 7e54d9df9f format? 2026-06-07 23:27:59 -07:00
Prospector 5fc8171711 provide moderation package stuff with DI 2026-06-07 23:23:34 -07:00
Prospector 4943d71b68 prepr 2026-06-07 22:35:04 -07:00
Prospector 7f972c4bc3 Merge remote-tracking branch 'origin/main' into boris/dev-886-modpack-project
# Conflicts:
#	apps/frontend/src/composables/featureFlags.ts
#	apps/frontend/src/locales/es-419/index.json
2026-06-07 22:34:40 -07:00
Prospector 5050da81e0 qa items 2026-06-07 22:32:50 -07:00
Prospector a6be110734 clean up fetching + add a couple missing features 2026-06-07 12:49:01 -07:00
Prospector f135f121e3 new version page 2026-06-07 09:49:51 -07:00
aecsocket 9834e86d4f default deser FMA field 2026-06-07 17:07:05 +01:00
aecsocket 471b67fe5a Track what moderator reviewed an attribution moderation status 2026-06-02 13:14:57 +01:00
aecsocket ed4d7594d7 updated by moderator info 2026-06-02 12:43:20 +01:00
aecsocket cccaf1eee6 return attribution resolution correctly 2026-06-02 12:19:05 +01:00
aecsocket e2f2fd8e65 should group by project 2026-06-01 23:21:50 +01:00
aecsocket 888d70f629 Merge branch 'main' into boris/dev-886-modpack-project 2026-06-01 17:21:31 +01:00
Prospector 04bdbd2d0a prepr 2026-05-28 17:47:08 -07:00
Prospector 78b617c25c Merge remote-tracking branch 'origin/main' into boris/dev-886-modpack-project
# Conflicts:
#	packages/assets/generated-icons.ts
2026-05-28 17:46:41 -07:00
Prospector dc1c3c8662 QA 2026-05-28 15:49:22 -07:00
2175be5bda feat: coolbot moderation changes (#6215)
* Update moderator checklist

* move permissions stage order

* Updated nagContext.versions to v3, added nag for permissions

* Update permissions.vue default messages

* prepr

---------

Co-authored-by: coolbot100s <76798835+coolbot100s@users.noreply.github.com>
2026-05-28 11:46:22 -07:00
aecsocket edb82b5304 Merge branch 'main' into boris/dev-886-modpack-project 2026-05-27 18:19:39 +01:00
aecsocket 4653b0c803 fix how hex bytes are handled in route 2026-05-27 12:25:42 +01:00
aecsocket 7a420f03f4 Prioritise putting override files in existing groups even with ExternalLicense 2026-05-27 12:14:06 +01:00
Prospector ecd77812ce delete file with weird name 2026-05-25 16:11:48 -07:00
aecsocket 8bc26f2cf1 query flame even if entry in MEL 2026-05-25 15:33:50 +01:00
aecsocket 99c77d756e change hash insert to bulk route 2026-05-25 14:42:18 +01:00
aecsocket af2224d44b include link in NoPermission 2026-05-25 14:37:37 +01:00
Prospector c8b64df900 pushing what i've got rn 2026-05-24 18:13:26 -07:00
Prospector 8658dbf3db Merge remote-tracking branch 'origin/main' into boris/dev-886-modpack-project 2026-05-24 12:14:38 -07:00
Prospector e8114a3614 Merge remote-tracking branch 'origin/main' into boris/dev-886-modpack-project 2026-05-24 11:41:15 -07:00
aecsocket 3cd1a06243 wip: bulk routes 2026-05-23 16:32:00 +01:00
aecsocket 145c79fa51 prepare 2026-05-21 16:51:02 +01:00
aecsocket 690f5c148b Version grandfathering 2026-05-21 16:50:25 +01:00
aecsocket cee57ffe6e fix routes 2026-05-21 16:01:20 +01:00
aecsocket 84bcf366b1 routes 2026-05-21 15:46:38 +01:00
aecsocket 2fecbe9c95 fixes 2026-05-21 15:45:25 +01:00
aecsocket 578b8df720 sqlx prepare 2026-05-21 15:30:58 +01:00
aecsocket 611e308134 fixes 2026-05-21 15:29:58 +01:00
aecsocket 549e9d5554 attribution on dependencies 2026-05-21 15:29:58 +01:00
Prospector 25c63b542c prepr 2026-05-20 11:42:52 -07:00
Prospector 61209ac0e1 update frontend stuff to new schema 2026-05-20 11:21:36 -07:00
tdgao 3c29a62036 fix: 2 factor auth enter code screen styles 2026-05-20 12:14:35 -06:00
Prospector 9071f7dee7 fix admonition 2026-05-20 11:09:04 -07:00
Prospector 50eaecf86d Merge remote-tracking branch 'origin/main' into boris/dev-886-modpack-project 2026-05-20 11:04:20 -07:00
aecsocket 6de97cc0a8 prepare 2026-05-19 17:06:08 +01:00
aecsocket 0bfa16295d fix deletion and file fetch 2026-05-19 16:22:15 +01:00
aecsocket 5d7a77cdfc Merge branch 'main' into boris/dev-886-modpack-project 2026-05-19 14:22:44 +01:00
aecsocket 876a4b649d Merge branch 'main' into boris/dev-886-modpack-project 2026-05-19 14:03:19 +01:00
aecsocket e16884aec5 fmt 2026-05-17 12:50:41 +01:00
aecsocket e11cd519cd fix schema 2026-05-16 18:18:06 +01:00
Prospector 9ae1ddc5ab remove feature flag, fix optional proof images 2026-05-16 11:11:40 -07:00
aecsocket cf02d151cc changes 2026-05-16 17:57:07 +01:00
aecsocket 61ff32a58b Merge branch 'main' into boris/dev-886-modpack-project 2026-05-16 16:46:34 +01:00
aecsocket 8d82bf38b1 sqlx prepare 2026-05-16 16:45:40 +01:00
aecsocket 2d3b4ab54c Add schema to attribution resolution and Flame project results 2026-05-16 16:36:20 +01:00
Prospector 3eb76da8af prepr 2026-05-16 00:20:49 -07:00
Prospector 00993a3ff8 moar frontend 2026-05-16 00:20:00 -07:00
Prospector 1f28196649 use api-client for img upload 2026-05-15 09:55:42 -07:00
ProspectorandGitHub 030dc2b3f7 Merge branch 'main' into boris/dev-886-modpack-project 2026-05-15 09:44:39 -07:00
Prospector 3801039771 prepr 2026-05-15 09:44:13 -07:00
Prospector c75e17ec0e frontend work 2026-05-15 09:26:54 -07:00
aecsocket 7315c35a41 withholding version visibility 2026-05-14 19:37:29 +01:00
aecsocket 407aa4b4cf Merge branch 'main' into boris/dev-908-backend-changes 2026-05-13 12:22:56 +01:00
aecsocket d709bbeb54 squash commits 2026-05-12 15:44:12 +01:00
Prospectorandaecsocket b5662371d8 pin tanstack versions + set pnpm min age to 7 days 2026-05-12 12:28:55 +01:00
Prospectorandaecsocket db9689b335 fix: open modrinth project links in the app (#6072) 2026-05-12 12:28:55 +01:00
Truman Gaoandaecsocket 0f1483d9df feat: improve add dependency flow (#6075)
* fix: shadow on nav

* feat: improve add dependency flow

* feat: update suggested dependency style

* feat: update dependency rows to use version number and update styles

* feat: implement combobox select searched text on focus

* feat: add Tabs.vue

* feat: update nav tabs to use tabs

* feat: improve project search dropdown

* fix: dependency search not clearing inbound query

* fix: combobox no options open state bug

* feat: improve dependency project and version search
2026-05-12 12:28:55 +01:00
Truman Gaoandaecsocket d48ad873fb fix: cannot hover over project card tooltip items (#6071)
fix: cannot hover over project cards
2026-05-12 12:28:55 +01:00
tdgao ff7c0cb5b8 fix: edit copy on info banner 2026-05-11 15:13:06 -06:00
tdgao faed8c3f6d fix: remove default date to open 13 years ago 2026-05-11 15:02:46 -06:00
tdgao 63d00cb660 fix: account.vue 2026-05-11 14:55:26 -06:00
tdgao 27d54a98af Merge branch 'main' into boris/dev-908-backend-changes 2026-05-11 14:50:39 -06:00
aecsocket c07b7afef6 expose version date published 2026-05-09 15:18:55 +01:00
Prospector 0d488d162e Merge remote-tracking branch 'origin/main' into boris/dev-886-modpack-project 2026-05-09 03:03:18 -07:00
Prospector 867aadc2e5 hook up frontend to backend (mostly) 2026-05-09 03:03:04 -07:00
aecsocket b6b5271e11 return extended version info + attributed at/by 2026-05-07 15:46:08 +01:00
aecsocket 2ee217bdff add files_missing_attributions to versions 2026-05-07 15:44:13 +01:00
aecsocket 9819611217 wip: override file sources 2026-05-07 15:44:12 +01:00
Prospector 1fdaf8e001 prepr 2026-05-06 22:22:25 -07:00
aecsocket 84888dc788 remove dep sha1 stuff 2026-05-06 13:12:39 +01:00
aecsocket 91d74e7705 Merge branch 'main' into boris/dev-886-modpack-project 2026-05-06 10:57:19 +01:00
Prospector a0471dad2b Merge remote-tracking branch 'origin/main' into boris/dev-886-modpack-project
# Conflicts:
#	apps/frontend/src/composables/featureFlags.ts
#	apps/frontend/src/pages/[type]/[id]/settings.vue
#	apps/frontend/src/pages/[type]/[id]/settings/permissions.vue
#	apps/frontend/src/pages/[type]/[id]/settings/versions.vue
#	apps/frontend/src/pages/moderation/external-projects.vue
#	packages/ui/src/components/external_files/ExternalProjectLookupCard.vue
#	packages/ui/src/components/external_files/index.ts
2026-05-05 13:01:07 -07:00
aecsocket bfd592487e add routes 2026-05-03 17:26:53 +01:00
aecsocket d53d5c4538 Merge branch 'main' into boris/dev-886-modpack-project 2026-05-03 13:12:16 +01:00
aecsocket e30cf265a4 insert attribution info after resolving 2026-05-03 13:10:49 +01:00
aecsocket 1b26f55f1e works 2026-05-02 14:13:21 +01:00
aecsocket d983f115ed attribution scanning basic works 2026-05-02 13:28:33 +01:00
aecsocket f01d149603 update s3 file host 2026-05-02 12:55:20 +01:00
aecsocket b93f6c682c wip: attributions 2026-05-02 12:51:33 +01:00
aecsocket 220733fa2f clean up version files 2026-05-02 12:38:29 +01:00
aecsocket e33b1dcec5 add dependency sha1 field 2026-05-01 22:12:30 +01:00
aecsocket 796049f6b0 Merge branch 'main' into boris/dev-886-modpack-project 2026-05-01 20:42:25 +01:00
aecsocket 05a1a10788 fmt 2026-04-30 21:48:37 +01:00
tdgao addb6c6045 feat: add default date open view 2026-04-30 14:05:37 -06:00
aecsocket e08da23fb5 fix lints 2026-04-30 20:39:00 +01:00
tdgao ecd69f88b2 fix: hover color bad on number input 2026-04-30 11:19:56 -06:00
tdgao d834ce3645 fix: add width on date 2026-04-30 11:00:08 -06:00
tdgao cf58559381 feat: improve styles 2026-04-30 10:55:05 -06:00
tdgao 01a7ee8d1e fix: date picker icons 2026-04-30 10:28:23 -06:00
tdgao 5480826a53 fix: number input icons color 2026-04-30 10:19:01 -06:00
tdgao 9184de92df fix: calculate age based on user's timezone 2026-04-30 10:11:58 -06:00
tdgao d753b8735c fix: date picker can be null 2026-04-30 10:07:48 -06:00
aecsocket c7a5336a51 increase recursion limit 2026-04-28 23:56:47 +01:00
aecsocket 292b8f4cd2 Merge branch 'main' into boris/dev-908-backend-changes 2026-04-28 23:38:50 +01:00
aecsocket c391fb12b9 update rust toolchain 2026-04-28 23:36:43 +01:00
tdgao 62f20b9aee feat: implement preserve date for date picker 2026-04-27 14:57:20 -06:00
tdgao 2aca44bbcc feat: implement date picker component in create account 2026-04-27 14:16:32 -06:00
tdgao b9be6abf0b fix: range select border styles 2026-04-27 14:06:30 -06:00
tdgao a5be0114c5 refactor: pnpm prepr 2026-04-27 13:49:09 -06:00
tdgao f32f732c31 feat: improve UX and styles for range select 2026-04-27 13:35:32 -06:00
tdgao e974672e71 feat: implement date picker component 2026-04-27 13:18:27 -06:00
tdgao d6356ea8e5 feat: small style updates 2026-04-27 12:19:47 -06:00
tdgao 872dcbcf78 fix: authorize.vue error states 2026-04-27 12:03:43 -06:00
tdgao 44da0ed5ff refactor: convert authorize.vue to use typescript 2026-04-27 11:49:17 -06:00
tdgao ec7c893681 refactor: convert verify-email to use typescript 2026-04-27 11:14:58 -06:00
tdgao 3ce8f85463 refactor: use typescript for reset-password 2026-04-27 11:13:12 -06:00
Calum H.andGitHub 4ce82b01e6 Merge branch 'main' into boris/dev-908-backend-changes 2026-04-27 13:12:48 +01:00
aecsocket 15f67eae3d scan files when inserting version file 2026-04-25 14:28:58 +01:00
aecsocket d846ef7f7d file host read capability 2026-04-25 14:28:58 +01:00
aecsocket 8d4076f8f0 improve FileHost ref counting 2026-04-25 14:28:58 +01:00
aecsocket bc9e586092 wip: override license checks 2026-04-25 14:28:58 +01:00
aecsocket e1f44b861e wip: when uploading a version file, fetch its overrides as a list 2026-04-25 14:28:58 +01:00
aecsocket 81abcf6403 include filename in external files 2026-04-25 14:28:58 +01:00
aecsocket 873a12bae5 MEL database backend routes 2026-04-25 14:28:58 +01:00
Prospectorandaecsocket 2d2598b221 begin project page permissions settings 2026-04-25 14:28:58 +01:00
Prospectorandaecsocket 804f6ca031 add copy link button 2026-04-25 14:28:58 +01:00
Prospectorandaecsocket fd28721d35 Begin external projects moderator database frontend 2026-04-25 14:28:57 +01:00
tdgao f4d65437df refactor: pnpm prepr 2026-04-24 15:58:57 -06:00
tdgao 9646083025 refactor: use typescript 2026-04-24 15:55:52 -06:00
tdgao 652a9b0b5c fix: add initOnMounted for SSR race 2026-04-24 15:37:32 -06:00
tdgao 918c4a9c83 fix: last signed in style 2026-04-24 15:19:05 -06:00
tdgao 0a0b19233d Merge branch 'boris/dev-908-backend-changes' of github.com:modrinth/code into boris/dev-908-backend-changes 2026-04-24 15:10:43 -06:00
tdgao 6f2e04c3de feat: handle app create account 2026-04-24 15:10:41 -06:00
Calum H. (IMB11) ece35f9158 Revert "chore: temp staging undo"
This reverts commit cad6bd4f92.
2026-04-24 22:04:42 +01:00
Calum H. (IMB11) 3ccb8132d8 Merge branch 'boris/dev-908-backend-changes' of https://github.com/modrinth/code into boris/dev-908-backend-changes 2026-04-24 22:02:50 +01:00
Calum H. (IMB11) cad6bd4f92 chore: temp staging undo 2026-04-24 22:02:49 +01:00
tdgao 65bd8937a6 fix: hover effect on native date picker 2026-04-24 14:55:06 -06:00
tdgao abcac5f40a refactor: pnpm prepr 2026-04-24 14:46:05 -06:00
tdgao 43893158ac fix: qa issues 2026-04-24 14:43:57 -06:00
tdgao fcab720943 fix: last sign in badge color 2026-04-24 13:36:31 -06:00
tdgao 4183702a72 refactor: pnpm prepr 2026-04-24 13:02:46 -06:00
tdgao 8946e4c394 fix: append number when generated name from email is taken 2026-04-24 13:00:51 -06:00
tdgao f1ce31001a feat: implement last signed in indicator 2026-04-24 12:51:05 -06:00
tdgao c99564f8ea refactor: pnpm prepr 2026-04-24 11:38:39 -06:00
tdgao 71c1deb5cd fix: account settings modals to use new modal and fix lots of bad styles 2026-04-24 11:37:41 -06:00
tdgao 042dde075b refactor: bring root page card styles into individual components and use tailwind 2026-04-24 11:12:49 -06:00
tdgao 3bfe670d47 remove: auth/welcome page as its no longer used 2026-04-24 10:19:56 -06:00
tdgao ed53fe5330 fix: copy 2026-04-24 10:19:38 -06:00
tdgao 547921badc fix: hcaptcha styles 2026-04-24 10:12:05 -06:00
Michael H. aac61dcfaa fix: make sure staging uses staging 2026-04-24 16:26:21 +02:00
tdgao 52a972d949 refactor: pnpm prepr 2026-04-22 12:18:42 -06:00
tdgao bdc2b4abf8 Merge branch 'main' into boris/dev-908-backend-changes 2026-04-22 09:00:20 -06:00
Truman GaoandGitHub c19ce11ea4 feat: new auth flow (#5840)
* update auth with new designs

* refactor: auth.js to auth.ts

* refactor: componentize auth pages

* fix: auth pages height

* feat: initial implementation of new sign-in oauth

* fix create account flow

* fix checkbox

* remove hard coded username

* implement create user validation endpoint and add more specific error responses

* feat: implement under 13 DOB guard and email/password validation route

* fix: TOCTOU issue
2026-04-22 07:57:17 -07:00
tdgao c54906e6de Merge branch 'main' into boris/dev-908-backend-changes 2026-04-17 17:08:04 -06:00
tdgao 57b4f21080 fix: backend response for create oauth account 2026-04-17 09:45:32 -06:00
aecsocket 66f3c39c13 fix flow 2026-04-14 19:24:30 +01:00
aecsocket 7484afa18e Revert "Use user-provided callback addr instead of SELF_ADDR"
This reverts commit 7ea0635d86.
2026-04-14 19:16:14 +01:00
aecsocket 7ea0635d86 Use user-provided callback addr instead of SELF_ADDR 2026-04-14 18:20:14 +01:00
aecsocket fe3aba52ab improve URL-related OAuth code 2026-04-14 12:35:13 +01:00
aecsocket 64f87551ff fix up oauth flow routes 2026-04-13 14:19:22 +01:00
aecsocket c8a586c6f1 Backend routes for choosing username in OAuth flow 2026-04-12 21:38:04 +01:00
351 changed files with 13856 additions and 3763 deletions
@@ -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>`.
+1
View File
@@ -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"
+2
View File
@@ -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>
@@ -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: {
@@ -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()
@@ -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,
@@ -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>
-179
View File
@@ -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`
}
+241
View File
@@ -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,
}),
}
+1 -1
View File
@@ -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"
},
+238 -49
View File
@@ -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": "Youre 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 isnt hosted on Modrinth, you need to let us know where its 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 isnt hosted on Modrinth, you need to let us know where its 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 {版本}}因未知的嵌入內容而被限制存取"
},
+4 -2
View File
@@ -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 isnt hosted on Modrinth, you need to let us know where its 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 isnt hosted on Modrinth, you need to let us know where its 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()
+9 -9
View File
@@ -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;
}
}
+127 -178
View File
@@ -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>
+97 -34
View File
@@ -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()
}
+105 -197
View File
@@ -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,
})
+203 -200
View File
@@ -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()
}
+118 -52
View File
@@ -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',
})
}
-205
View File
@@ -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:
'Youre 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>
+1 -1
View File
@@ -284,11 +284,11 @@ import {
VersionIcon,
XCircleIcon,
} from '@modrinth/assets'
import { defineMessage } from '@modrinth/ui'
import {
AutoLink,
Avatar,
ButtonStyled,
defineMessage,
defineMessages,
formatReportItemType,
injectNotificationManager,
+146 -127
View File
@@ -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>
+6 -2
View File
@@ -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 })
}
+1 -1
View File
@@ -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')
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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