Compare commits

...
Author SHA1 Message Date
tdgao 4897cffb8d Merge branch 'boris/dev-908-backend-changes' of github.com:modrinth/code into boris/dev-908-backend-changes 2026-06-25 13:49:51 -06:00
tdgao 339beddcbc pnpm prepr 2026-06-25 13:49:49 -06:00
Truman GaoandGitHub 73232b3b7f Merge branch 'main' into boris/dev-908-backend-changes 2026-06-25 13:49:42 -06:00
tdgao bc073581d2 fix copy 2026-06-25 13:46:57 -06:00
tdgao 4eabe2a126 feat: improve manage passkeys UI 2026-06-25 13:45:09 -06:00
tdgao 24285baebb fmt 2026-06-25 11:58:40 -06:00
Truman GaoandGitHub ae939cd4ca Merge branch 'main' into boris/dev-908-backend-changes 2026-06-25 11:13:28 -06:00
DeDiamondProandGitHub 8faa36b014 feat: add passkey support (#6375)
* feat: add passkey backend

* feat: passkey frontend

* invalidate sessions on compromised passkey

* chore: run sqlx prepare

* fix: make passkey button use both collumns to prevent empty space

* fix: correctly verify max passkeys in finish route

* fix: use structs for response

* fix: add rp name default

* style: use web::Json
2026-06-25 10:13:08 -07:00
tdgao a45a320512 fix: add stroke 2026-06-24 12:48:10 -06:00
tdgao 98c70633ee Merge branch 'main' into boris/dev-908-backend-changes 2026-06-24 11:05:06 -06:00
tdgao d858901568 Revert "Merge branch 'main' into boris/dev-908-backend-changes"
This reverts commit b9b03796e3.
2026-06-24 10:58:08 -06:00
tdgao b9b03796e3 Merge branch 'main' into boris/dev-908-backend-changes 2026-06-24 10:49:12 -06:00
tdgao e8475086ec fix: unused import 2026-06-23 13:19:40 -06:00
tdgao 655f899785 Merge branch 'main' into boris/dev-908-backend-changes 2026-06-23 12:48:00 -06:00
tdgao 9bcee5ab52 feat: update sign in screen 2026-06-23 12:45:18 -06:00
tdgao 9f050f21e6 improve reset password 2026-06-23 12:03:59 -06:00
tdgao 782c82d3e8 update copy 2026-06-23 11:54:38 -06:00
tdgao da828f4db8 update copy 2026-06-23 11:46:50 -06:00
aecsocket f6f2cb8d8b Merge branch 'main' into boris/dev-908-backend-changes 2026-06-22 19:50:47 +01:00
aecsocket 1a0633b6db Merge branch 'main' into boris/dev-908-backend-changes 2026-06-11 21:31:23 +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
tdgao 3c29a62036 fix: 2 factor auth enter code screen styles 2026-05-20 12:14:35 -06: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 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
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
125 changed files with 5170 additions and 2828 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>`.
Generated
+427 -43
View File
@@ -477,7 +477,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"cpufeatures 0.2.17",
"password-hash",
]
@@ -494,7 +494,7 @@ dependencies = [
"serde_json",
"thiserror 2.0.17",
"utoipa",
"uuid 1.18.1",
"uuid 1.23.3",
]
[[package]]
@@ -530,6 +530,45 @@ dependencies = [
"zbus",
]
[[package]]
name = "asn1-rs"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048"
dependencies = [
"asn1-rs-derive",
"asn1-rs-impl",
"displaydoc",
"nom 7.1.3",
"num-traits",
"rusticata-macros",
"thiserror 1.0.69",
"time",
]
[[package]]
name = "asn1-rs-derive"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
"synstructure",
]
[[package]]
name = "asn1-rs-impl"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "astral-tokio-tar"
version = "0.5.6"
@@ -1000,7 +1039,7 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"tracing",
"uuid 1.18.1",
"uuid 1.23.3",
]
[[package]]
@@ -1369,6 +1408,17 @@ version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
[[package]]
name = "base64urlsafedata"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b08e33815c87d8cadcddb1e74ac307368a3751fbe40c961538afa21a1899f21c"
dependencies = [
"base64 0.21.7",
"pastey",
"serde",
]
[[package]]
name = "bindgen"
version = "0.72.1"
@@ -1831,7 +1881,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
dependencies = [
"byteorder",
"fnv",
"uuid 1.18.1",
"uuid 1.23.3",
]
[[package]]
@@ -1856,6 +1906,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
[[package]]
name = "chardetng"
version = "0.1.17"
@@ -2004,7 +2065,7 @@ dependencies = [
"time",
"tokio",
"url",
"uuid 1.18.1",
"uuid 1.23.3",
]
[[package]]
@@ -2332,6 +2393,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crc"
version = "3.3.0"
@@ -2706,7 +2776,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d"
dependencies = [
"serde",
"uuid 1.18.1",
"uuid 1.23.3",
]
[[package]]
@@ -2726,6 +2796,20 @@ dependencies = [
"zeroize",
]
[[package]]
name = "der-parser"
version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
dependencies = [
"asn1-rs",
"displaydoc",
"nom 7.1.3",
"num-bigint",
"num-traits",
"rusticata-macros",
]
[[package]]
name = "deranged"
version = "0.5.4"
@@ -3871,11 +3955,25 @@ dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"r-efi 5.3.0",
"wasi 0.14.7+wasi-0.2.4",
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasip2",
"wasip3",
]
[[package]]
name = "gif"
version = "0.13.3"
@@ -4720,6 +4818,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ident_case"
version = "1.0.1"
@@ -5308,8 +5412,10 @@ dependencies = [
"utoipa",
"utoipa-actix-web",
"utoipa-scalar",
"uuid 1.18.1",
"uuid 1.23.3",
"validator",
"webauthn-rs",
"webauthn-rs-proto",
"webp",
"woothee",
"yaserde",
@@ -5342,6 +5448,12 @@ dependencies = [
"spin 0.9.8",
]
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lebe"
version = "0.5.3"
@@ -5755,7 +5867,7 @@ dependencies = [
"thiserror 2.0.17",
"time",
"tokio",
"uuid 1.18.1",
"uuid 1.23.3",
"wasm-bindgen-futures",
"web-sys",
"yaup",
@@ -5895,7 +6007,7 @@ dependencies = [
"rustc_version",
"smallvec",
"tagptr",
"uuid 1.18.1",
"uuid 1.23.3",
]
[[package]]
@@ -5946,7 +6058,7 @@ dependencies = [
"serde_with",
"strum",
"utoipa",
"uuid 1.18.1",
"uuid 1.23.3",
]
[[package]]
@@ -6581,6 +6693,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "oid-registry"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9"
dependencies = [
"asn1-rs",
]
[[package]]
name = "once_cell"
version = "1.21.3"
@@ -6611,15 +6732,14 @@ dependencies = [
[[package]]
name = "openssl"
version = "0.10.73"
version = "0.10.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
dependencies = [
"bitflags 2.9.4",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
@@ -6643,9 +6763,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
version = "0.9.109"
version = "0.9.116"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571"
checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
dependencies = [
"cc",
"libc",
@@ -6949,6 +7069,12 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pastey"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "path-util"
version = "0.0.0"
@@ -7777,6 +7903,12 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "r2d2"
version = "0.8.10"
@@ -7829,6 +7961,17 @@ dependencies = [
"rand_core 0.9.3",
]
[[package]]
name = "rand"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
dependencies = [
"chacha20",
"getrandom 0.4.2",
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.2.2"
@@ -7886,6 +8029,12 @@ dependencies = [
"getrandom 0.3.3",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_hc"
version = "0.2.0"
@@ -8313,7 +8462,7 @@ dependencies = [
"rkyv_derive",
"seahash",
"tinyvec",
"uuid 1.18.1",
"uuid 1.23.3",
]
[[package]]
@@ -8452,6 +8601,15 @@ dependencies = [
"semver",
]
[[package]]
name = "rusticata-macros"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
dependencies = [
"nom 7.1.3",
]
[[package]]
name = "rustix"
version = "0.38.44"
@@ -8696,7 +8854,7 @@ dependencies = [
"serde",
"serde_json",
"url",
"uuid 1.18.1",
"uuid 1.23.3",
]
[[package]]
@@ -8971,7 +9129,7 @@ dependencies = [
"thiserror 2.0.17",
"time",
"url",
"uuid 1.18.1",
"uuid 1.23.3",
]
[[package]]
@@ -9028,6 +9186,16 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_cbor_2"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c"
dependencies = [
"half 2.7.0",
"serde",
]
[[package]]
name = "serde_core"
version = "1.0.228"
@@ -9274,7 +9442,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@@ -9291,7 +9459,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@@ -9547,7 +9715,7 @@ dependencies = [
"tokio-stream",
"tracing",
"url",
"uuid 1.18.1",
"uuid 1.23.3",
"webpki-roots 0.26.11",
]
@@ -9630,7 +9798,7 @@ dependencies = [
"stringprep",
"thiserror 2.0.17",
"tracing",
"uuid 1.18.1",
"uuid 1.23.3",
"whoami",
]
@@ -9670,7 +9838,7 @@ dependencies = [
"stringprep",
"thiserror 2.0.17",
"tracing",
"uuid 1.18.1",
"uuid 1.23.3",
"whoami",
]
@@ -9697,7 +9865,7 @@ dependencies = [
"thiserror 2.0.17",
"tracing",
"url",
"uuid 1.18.1",
"uuid 1.23.3",
]
[[package]]
@@ -10162,7 +10330,7 @@ dependencies = [
"thiserror 2.0.17",
"time",
"url",
"uuid 1.18.1",
"uuid 1.23.3",
"walkdir",
]
@@ -10470,7 +10638,7 @@ dependencies = [
"toml 0.9.8",
"url",
"urlpattern",
"uuid 1.18.1",
"uuid 1.23.3",
"walkdir",
]
@@ -10626,7 +10794,7 @@ dependencies = [
"tracing-error",
"tracing-subscriber",
"url",
"uuid 1.18.1",
"uuid 1.23.3",
"whoami",
"windows",
"windows-core 0.61.2",
@@ -10674,7 +10842,7 @@ dependencies = [
"tracing-error",
"url",
"urlencoding",
"uuid 1.18.1",
"uuid 1.23.3",
"webview2-com",
"windows",
"windows-core 0.61.2",
@@ -11163,9 +11331,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.41"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
@@ -11183,14 +11351,14 @@ dependencies = [
"mutually_exclusive_features",
"pin-project",
"tracing",
"uuid 1.18.1",
"uuid 1.23.3",
]
[[package]]
name = "tracing-attributes"
version = "0.1.30"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
@@ -11199,9 +11367,9 @@ dependencies = [
[[package]]
name = "tracing-core"
version = "0.1.34"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
@@ -11597,7 +11765,7 @@ dependencies = [
"regex",
"syn 2.0.106",
"url",
"uuid 1.18.1",
"uuid 1.23.3",
]
[[package]]
@@ -11623,14 +11791,14 @@ dependencies = [
[[package]]
name = "uuid"
version = "1.18.1"
version = "1.23.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2"
checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7"
dependencies = [
"getrandom 0.3.3",
"getrandom 0.4.2",
"js-sys",
"rand 0.9.2",
"serde",
"rand 0.10.1",
"serde_core",
"wasm-bindgen",
]
@@ -11805,7 +11973,16 @@ version = "1.0.1+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
dependencies = [
"wit-bindgen",
"wit-bindgen 0.46.0",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen 0.51.0",
]
[[package]]
@@ -11873,6 +12050,28 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap 2.11.4",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasm-streams"
version = "0.4.2"
@@ -11899,6 +12098,18 @@ dependencies = [
"web-sys",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.9.4",
"hashbrown 0.15.5",
"indexmap 2.11.4",
"semver",
]
[[package]]
name = "wayland-backend"
version = "0.3.11"
@@ -11979,6 +12190,74 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "webauthn-attestation-ca"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6475c0bbd1a3f04afaa3e98880408c5be61680c5e6bd3c6f8c250990d5d3e18e"
dependencies = [
"base64urlsafedata",
"openssl",
"openssl-sys",
"serde",
"tracing",
"uuid 1.23.3",
]
[[package]]
name = "webauthn-rs"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c548915e0e92ee946bbf2aecf01ea21bef53d974b0793cc6732ba81a03fc422"
dependencies = [
"base64urlsafedata",
"serde",
"tracing",
"url",
"uuid 1.23.3",
"webauthn-rs-core",
]
[[package]]
name = "webauthn-rs-core"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "296d2d501feb715d80b8e186fb88bab1073bca17f460303a1013d17b673bea6a"
dependencies = [
"base64 0.21.7",
"base64urlsafedata",
"der-parser",
"hex",
"nom 7.1.3",
"openssl",
"openssl-sys",
"rand 0.9.2",
"rand_chacha 0.9.0",
"serde",
"serde_cbor_2",
"serde_json",
"thiserror 1.0.69",
"tracing",
"url",
"uuid 1.23.3",
"webauthn-attestation-ca",
"webauthn-rs-proto",
"x509-parser",
]
[[package]]
name = "webauthn-rs-proto"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c37393beac9c1ed1ca6dbb30b1e01783fb316ab3a45d90ecd48c99052dd7ef1e"
dependencies = [
"base64 0.21.7",
"base64urlsafedata",
"serde",
"serde_json",
"url",
]
[[package]]
name = "webkit2gtk"
version = "2.0.2"
@@ -12701,6 +12980,94 @@ version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck 0.5.0",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck 0.5.0",
"indexmap 2.11.4",
"prettyplease",
"syn 2.0.106",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn 2.0.106",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags 2.9.4",
"indexmap 2.11.4",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap 2.11.4",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "woothee"
version = "0.13.0"
@@ -12792,6 +13159,23 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "x509-parser"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69"
dependencies = [
"asn1-rs",
"data-encoding",
"der-parser",
"lazy_static",
"nom 7.1.3",
"oid-registry",
"rusticata-macros",
"thiserror 1.0.69",
"time",
]
[[package]]
name = "xattr"
version = "1.6.1"
+2
View File
@@ -220,6 +220,8 @@ utoipa-actix-web = { version = "0.1.2" }
utoipa-scalar = { version = "0.3.0", default-features = false }
uuid = "1.18.1"
validator = "0.20.0"
webauthn-rs = "0.5.5"
webauthn-rs-proto = "0.5.5"
webp = { version = "0.3.1", default-features = false }
webview2-com = "0.38.0" # Should be updated in lockstep with wry
whoami = "1.6.1"
+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>
@@ -0,0 +1,271 @@
<template>
<div
class="shadow-card mx-auto flex w-full max-w-[27rem] flex-col gap-6 rounded-2xl border border-solid 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)"
/>
<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) }}
</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.',
},
under13HelperText: {
id: 'auth.create-account.date-of-birth.under13-helper',
defaultMessage: 'You do not meet the age requirement to create an account at Modrinth.',
},
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. We only use it during sign-up to validate COPPA compliance.',
},
relevantSourceCodeText: {
id: 'auth.create-account.info-panel.source-code-link',
defaultMessage: 'Relevant source code',
},
usernameOptionalLabel: {
id: 'auth.create-account.username.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,416 @@
<template>
<div class="contents">
<ConfirmModal
ref="removePasskeyModal"
:title="formatMessage(messages.removePasskeyConfirmTitle)"
:description="
formatMessage(messages.removePasskeyConfirmDescription, { name: passkeyToRemove?.name })
"
:proceed-label="formatMessage(commonMessages.removeButton)"
@proceed="removePasskey()"
/>
<NewModal
ref="managePasskeyModal"
width="600px"
:header="formatMessage(messages.managePasskeyTitle)"
>
<div class="flex flex-col gap-4">
<div v-if="passkeysLoading" class="flex items-center gap-2 text-secondary">
<SpinnerIcon class="animate-spin" />
{{ formatMessage(messages.managePasskeyModalLoading) }}
</div>
<template v-else>
<p v-if="passkeys.length === 0" class="m-0 text-secondary">
{{ formatMessage(messages.managePasskeyModalNoPasskeys) }}
</p>
<div
v-for="passkey in passkeys"
:key="passkey.id"
class="universal-card recessed flex items-center justify-between"
>
<div>
<div>
<strong>{{ passkey.name }}</strong>
</div>
<div>
<span v-tooltip="formatDateTime(passkey.created_at)">
{{
formatMessage(messages.managePasskeyModalAdded, {
ago: formatRelativeTime(passkey.created_at),
})
}}
</span>
<span v-if="passkey.last_used" v-tooltip="formatDateTime(passkey.last_used)">
{{
formatMessage(messages.managePasskeyModalLastUsed, {
ago: formatRelativeTime(passkey.last_used),
})
}}
</span>
<span v-else>
{{ formatMessage(messages.managePasskeyModalNeverUsed) }}
</span>
</div>
</div>
<div class="flex gap-2">
<ButtonStyled circular>
<button
v-tooltip="formatMessage(commonMessages.renameButton)"
@click="
() => {
passkeyToRename = { ...passkey }
renamePasskeyModal?.show()
}
"
>
<EditIcon />
</button>
</ButtonStyled>
<ButtonStyled circular>
<button
v-tooltip="formatMessage(commonMessages.removeButton)"
@click="
() => {
passkeyToRemove = passkey
removePasskeyModal?.show()
}
"
>
<TrashIcon />
</button>
</ButtonStyled>
</div>
</div>
</template>
<div class="input-group self-end">
<ButtonStyled>
<button @click="registerPasskey()">
<PlusIcon />
{{ formatMessage(messages.managePasskeyAddPasskey) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="managePasskeyModal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.closeButton) }}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<NewModal
ref="addPasskeyModal"
width="500px"
:header="formatMessage(messages.managePasskeyAddPasskey)"
>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<div class="text-md font-semibold text-contrast">
{{ formatMessage(messages.passkeyNameLabel) }}
</div>
<StyledInput
id="passkey-name"
v-model="pendingPasskeyName"
:maxlength="255"
type="text"
:placeholder="formatMessage(messages.passkeyNamePlaceholder)"
@keyup.enter="pendingPasskeyName && finishRegisterPasskey()"
/>
<div class="label__description">
{{ formatMessage(messages.passkeyNameDescription) }}
</div>
</div>
<div class="flex justify-end gap-2.5">
<ButtonStyled>
<button @click="addPasskeyModal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!pendingPasskeyName" @click="finishRegisterPasskey()">
<PlusIcon />
{{ formatMessage(messages.managePasskeyAddPasskey) }}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<NewModal
ref="renamePasskeyModal"
width="500px"
:header="formatMessage(messages.renamePasskeyModalHeader)"
>
<div v-if="passkeyToRename" class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<div class="text-md font-semibold text-contrast">
{{ formatMessage(messages.passkeyNameLabel) }}
</div>
<StyledInput
id="passkey-rename"
v-model="passkeyToRenameName"
:maxlength="255"
type="text"
:placeholder="formatMessage(messages.passkeyNamePlaceholder)"
@keyup.enter="passkeyToRenameName && renamePasskey()"
/>
<div class="label__description">
{{ formatMessage(messages.passkeyNameDescription) }}
</div>
</div>
<div class="flex justify-end gap-2.5">
<ButtonStyled>
<button @click="renamePasskeyModal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!passkeyToRenameName" @click="renamePasskey()">
<SaveIcon />
{{ formatMessage(commonMessages.saveButton) }}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<div class="adjacent-input mb-0">
<label for="manage-passkeys">
<span class="label__title">{{ formatMessage(messages.managePasskeyTitle) }}</span>
<span class="label__description">{{
formatMessage(messages.managePasskeyDescription)
}}</span>
</label>
<div>
<ButtonStyled>
<button id="manage-passkeys" @click="showPasskeyModal">
<UserKeyIcon /> {{ formatMessage(messages.managePasskeyTitle) }}
</button>
</ButtonStyled>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
EditIcon,
PlusIcon,
SaveIcon,
SpinnerIcon,
TrashIcon,
UserKeyIcon,
XIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
ConfirmModal,
defineMessages,
injectModrinthClient,
injectNotificationManager,
NewModal,
StyledInput,
useFormatDateTime,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { computed, ref, useTemplateRef } from 'vue'
import { createPasskeyCredential } from '~/helpers/passkey.ts'
const { labrinth } = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
})
function notifyError(err: unknown) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err instanceof Error ? err.message : String(err),
type: 'error',
})
}
const messages = defineMessages({
managePasskeyTitle: {
id: 'settings.account.security.passkey.title',
defaultMessage: 'Manage passkeys',
},
managePasskeyDescription: {
id: 'settings.account.security.passkey.description',
defaultMessage: 'Manage your registered passkeys, or add a new one.',
},
managePasskeyAddPasskey: {
id: 'settings.account.security.passkey.add',
defaultMessage: 'Add passkey',
},
managePasskeyModalLoading: {
id: 'settings.account.security.passkey.modal.loading',
defaultMessage: 'Loading passkeys…',
},
managePasskeyModalNoPasskeys: {
id: 'settings.account.security.passkey.modal.no-passkeys',
defaultMessage: 'You do not have any passkeys registered.',
},
managePasskeyModalAdded: {
id: 'settings.account.security.passkey.modal.added',
defaultMessage: 'Added {ago}',
},
managePasskeyModalLastUsed: {
id: 'settings.account.security.passkey.modal.last-used',
defaultMessage: 'Last used {ago}',
},
managePasskeyModalNeverUsed: {
id: 'settings.account.security.passkey.modal.never-used',
defaultMessage: 'Never used',
},
passkeyNameLabel: {
id: 'settings.account.security.passkey.add-modal.name.label',
defaultMessage: 'Name',
},
passkeyNameDescription: {
id: 'settings.account.security.passkey.add-modal.name.description',
defaultMessage:
'Make sure to pick something memorable, so you can identify this passkey later.',
},
passkeyNamePlaceholder: {
id: 'settings.account.security.passkey.add-modal.name.placeholder',
defaultMessage: 'My passkey',
},
renamePasskeyModalHeader: {
id: 'settings.account.security.passkey.rename-modal.header',
defaultMessage: 'Rename passkey',
},
removePasskeyConfirmTitle: {
id: 'settings.account.security.passkey.remove.title',
defaultMessage: 'Are you sure you want to remove this passkey?',
},
removePasskeyConfirmDescription: {
id: 'settings.account.security.passkey.remove.description',
defaultMessage:
'This will permanently remove the passkey "{name}". You will no longer be able to sign in with it.',
},
})
const removePasskeyModal = useTemplateRef<InstanceType<typeof ConfirmModal>>('removePasskeyModal')
const managePasskeyModal = useTemplateRef<InstanceType<typeof NewModal>>('managePasskeyModal')
const addPasskeyModal = useTemplateRef<InstanceType<typeof NewModal>>('addPasskeyModal')
const renamePasskeyModal = useTemplateRef<InstanceType<typeof NewModal>>('renamePasskeyModal')
const pendingPasskey = ref<Labrinth.Auth.v2.PasskeyRegisterFinishRequest | null>(null)
const passkeyToRemove = ref<Labrinth.Auth.v2.Passkey | null>(null)
const passkeyToRename = ref<Labrinth.Auth.v2.Passkey | null>(null)
const pendingPasskeyName = computed({
get: () => pendingPasskey.value?.name ?? '',
set: (name) => {
if (pendingPasskey.value) {
pendingPasskey.value.name = name
}
},
})
const passkeyToRenameName = computed({
get: () => passkeyToRename.value?.name ?? '',
set: (name) => {
if (passkeyToRename.value) {
passkeyToRename.value.name = name
}
},
})
const passkeys = ref<Labrinth.Auth.v2.Passkey[]>([])
const passkeysLoading = ref(false)
async function fetchPasskeys() {
passkeysLoading.value = true
try {
passkeys.value = await labrinth.auth_v2.listPasskeys()
} catch (err) {
notifyError(err)
}
passkeysLoading.value = false
}
async function showPasskeyModal() {
managePasskeyModal.value?.show()
await fetchPasskeys()
}
async function registerPasskey() {
startLoading()
try {
const res = await labrinth.auth_v2.registerPasskeyStart()
const credential = await createPasskeyCredential(res.options.publicKey)
pendingPasskey.value = {
flow: res.flow,
credential: credential,
name: '',
}
addPasskeyModal.value?.show()
} catch (err) {
notifyError(err)
}
stopLoading()
}
async function finishRegisterPasskey() {
if (!pendingPasskey.value) return
startLoading()
try {
const passkey = await labrinth.auth_v2.registerPasskeyFinish(pendingPasskey.value)
passkeys.value.unshift(passkey)
pendingPasskey.value = null
addPasskeyModal.value?.hide()
} catch (err) {
notifyError(err)
}
stopLoading()
}
async function renamePasskey() {
if (!passkeyToRename.value) return
const { id, name } = passkeyToRename.value
startLoading()
try {
await labrinth.auth_v2.renamePasskey(id, { name })
const existing = passkeys.value.find((passkey) => passkey.id === id)
if (existing) {
existing.name = name
}
passkeyToRename.value = null
renamePasskeyModal.value?.hide()
} catch (err) {
notifyError(err)
}
stopLoading()
}
async function removePasskey() {
if (!passkeyToRemove.value) return
const { id } = passkeyToRemove.value
startLoading()
try {
await labrinth.auth_v2.deletePasskey(id)
passkeys.value = passkeys.value.filter((passkey) => passkey.id !== id)
passkeyToRemove.value = null
} catch (err) {
notifyError(err)
}
stopLoading()
}
</script>
@@ -0,0 +1,319 @@
<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-[27rem] flex-col gap-6 border border-solid border-surface-5 !p-6"
>
<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-5">
<div class="text-center text-2xl font-semibold text-contrast">
{{ formatMessage(messages.signInWithLabel) }}
</div>
<section class="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
<ButtonStyled v-for="provider in oauthProviders" :key="provider.id">
<a
class="relative w-full !justify-center overflow-visible !shadow-none"
:class="{
'!border !border-[var(--color-green)]': lastSignInProvider === provider.id,
}"
:href="getAuthUrl(provider.id, redirectTarget)"
:aria-label="
formatMessage(messages.continueWithProvider, { provider: provider.name })
"
@click="onOAuthProviderClick(provider.id)"
>
<component :is="provider.icon" />
<span>{{ provider.name }}</span>
<span
v-if="lastSignInProvider === provider.id"
class="oauth-provider-last-sign-in-badge"
>
{{ formatMessage(messages.lastSignInLabel) }}
</span>
</a>
</ButtonStyled>
<ButtonStyled>
<a
class="relative w-full !justify-center overflow-visible !shadow-none sm:col-span-2"
:class="{ '!border !border-[var(--color-green)]': lastSignInProvider === 'passkey' }"
role="button"
tabindex="0"
@click="onPasskeySignIn"
@keydown.enter="onPasskeySignIn"
>
<UserKeyIcon />
<span class="ml-1">{{ formatMessage(messages.continueWithPasskey) }}</span>
<span
v-if="lastSignInProvider === 'passkey'"
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="mx-auto flex w-full 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,
UserKeyIcon,
} 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'
const oauthProviders = [
{ id: 'discord', name: 'Discord', icon: DiscordColorIcon },
{ id: 'github', name: 'GitHub', icon: GitHubColorIcon },
{ id: 'microsoft', name: 'Microsoft', icon: MicrosoftColorIcon },
{ id: 'google', name: 'Google', icon: GoogleColorIcon },
{ id: 'steam', name: 'Steam', icon: SteamColorIcon },
{ id: 'gitlab', name: 'GitLab', icon: GitLabColorIcon },
] as const
type AuthProvider = (typeof oauthProviders)[number]['id'] | 'passkey'
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
onPasskeySignIn?: () => void
onSetCaptchaRef?: ((captchaRef: unknown) => void) | undefined
}
const {
subtleLauncherRedirectUri = '',
flow = '',
redirectTarget = '',
routeQuery = {},
globals = null,
onPasswordSignIn = () => {},
onTwoFactorSignIn = () => {},
onPasskeySignIn = () => {},
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 used',
},
continueWithPasskey: {
id: 'auth.sign-in.continue-with-passkey',
defaultMessage: 'Continue with passkey',
},
})
</script>
<style scoped lang="scss">
.oauth-provider-last-sign-in-badge {
position: absolute;
top: -0.75rem;
right: 0.25rem;
z-index: 1;
border-radius: 9999px;
background-color: var(--surface-3);
color: var(--color-green);
border: 1px solid var(--color-green);
padding: 0.125rem 0.375rem;
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-[27rem] flex-col gap-6 border border-solid border-surface-5 !p-6"
>
<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 } =
@@ -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'
@@ -31,13 +31,13 @@ import {
getProjectTypeIcon,
injectModrinthClient,
injectNotificationManager,
NavTabs,
OverflowMenu,
type OverflowMenuOption,
useFormatBytes,
useFormatDateTime,
useVIntl,
} 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
-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'
}
+95
View File
@@ -0,0 +1,95 @@
function ensurePasskeySupported() {
const supported =
typeof window !== 'undefined' &&
typeof window.PublicKeyCredential !== 'undefined' &&
typeof navigator !== 'undefined' &&
!!navigator.credentials
if (!supported) {
throw new Error('Passkeys are not supported by this browser.')
}
}
function base64urlToBuffer(base64url: string) {
return Uint8Array.from(atob(base64url.replace(/-/g, '+').replace(/_/g, '/')), (char) =>
char.charCodeAt(0),
)
}
function bufferToBase64url(buffer: ArrayBuffer) {
const bytes = new Uint8Array(buffer)
let str = ''
for (let i = 0; i < bytes.length; i++) {
str += String.fromCharCode(bytes[i])
}
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
/**
* Creates a passkey credential using the browser's WebAuthn API.
*
* @param options The public key options for creating the passkey credential, provided by the server.
*/
export async function createPasskeyCredential(options: any) {
ensurePasskeySupported()
const publicKey = {
...options,
challenge: base64urlToBuffer(options.challenge),
user: {
...options.user,
id: base64urlToBuffer(options.user.id),
},
excludeCredentials: options.excludeCredentials?.map((cred: any) => ({
...cred,
id: base64urlToBuffer(cred.id),
})),
}
const credential = (await navigator.credentials.create({ publicKey })) as PublicKeyCredential
const response = credential.response as AuthenticatorAttestationResponse
return {
id: credential.id,
rawId: bufferToBase64url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bufferToBase64url(response.clientDataJSON),
attestationObject: bufferToBase64url(response.attestationObject),
},
extensions: credential.getClientExtensionResults(),
}
}
/**
* Authenticates a user using a passkey credential.
*
* @param options The public key options for authenticating the passkey credential, provided by the server.
*/
export async function getPasskeyCredential(options: any) {
ensurePasskeySupported()
const publicKey = {
...options,
challenge: base64urlToBuffer(options.challenge),
allowCredentials: options.allowCredentials?.map((cred: any) => ({
...cred,
id: base64urlToBuffer(cred.id),
})),
}
const credential = (await navigator.credentials.get({ publicKey })) as PublicKeyCredential
const response = credential.response as AuthenticatorAssertionResponse
return {
id: credential.id,
rawId: bufferToBase64url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bufferToBase64url(response.clientDataJSON),
authenticatorData: bufferToBase64url(response.authenticatorData),
signature: bufferToBase64url(response.signature),
userHandle: response.userHandle ? bufferToBase64url(response.userHandle) : null,
},
extensions: credential.getClientExtensionResults(),
}
}
+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'
@@ -365,39 +365,21 @@
"auth.sign-in.2fa.placeholder": {
"message": "أدخل الرمز..."
},
"auth.sign-in.additional-options": {
"message": "<forgot-password-link>هل نسيت كلمة المرور؟</forgot-password-link> • <create-account-link>إنشاء حساب</create-account-link>"
},
"auth.sign-in.sign-in-with": {
"message": "تسجيل الدخول باستخدام"
},
"auth.sign-in.title": {
"message": "تسجيل الدخول"
},
"auth.sign-in.use-password": {
"message": "أو استخدم كلمة المرور"
},
"auth.sign-up.action.create-account": {
"message": "إنشاء حساب"
},
"auth.sign-up.legal-dislaimer": {
"message": "بإنشائك حسابًا، فإنك توافق على <terms-link>شروط مودرنث</terms-link> و<privacy-policy-link>سياسة الخصوصية</privacy-policy-link> الخاصة بها."
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "كلمتا المرور غير متطابقتين!"
},
"auth.sign-up.sign-in-option.title": {
"message": "هل لديك حساب بالفعل؟"
},
"auth.sign-up.subscribe.label": {
"message": "اشترك لتصلك التحديثات حول مودرنث"
},
"auth.sign-up.title": {
"message": "إنشاء حساب"
},
"auth.sign-up.title.create-account": {
"message": "أو أنشئ حسابًا بنفسك"
},
"auth.sign-up.title.sign-up-with": {
"message": "سجّل باستخدام"
},
@@ -431,21 +413,6 @@
"auth.verify-email.title": {
"message": "تحقّق من البريد الإلكتروني"
},
"auth.welcome.checkbox.subscribe": {
"message": "اشترك في التحديثات حول مودرنث"
},
"auth.welcome.description": {
"message": "أنت الآن جزء من مجتمع رائع من المبدعين والمستكشفين الذين يقومون بالفعل بإنشاء المودات المذهلة، تثبيتها، والبقاء على اطلاع بآخر التحديثات."
},
"auth.welcome.label.tos": {
"message": "بإنشائك حسابًا، فإنك توافق على <terms-link>شروط مودرنث</terms-link> و<privacy-policy-link>سياسة الخصوصية</privacy-policy-link>."
},
"auth.welcome.long-title": {
"message": "مرحبًا بك في مودرنث!"
},
"auth.welcome.title": {
"message": "مرحبًا"
},
"collection.button.edit-icon": {
"message": "تعديل الأيقونة"
},
@@ -407,39 +407,21 @@
"auth.sign-in.2fa.placeholder": {
"message": "Zadejte kód..."
},
"auth.sign-in.additional-options": {
"message": "<forgot-password-link>Zapomněli jste heslo?</forgot-password-link> • <create-account-link>Vytvořte si účet</create-account-link>"
},
"auth.sign-in.sign-in-with": {
"message": "Přihlášte se pomocí"
},
"auth.sign-in.title": {
"message": "Přihlásit se"
},
"auth.sign-in.use-password": {
"message": "Nebo použijte heslo"
},
"auth.sign-up.action.create-account": {
"message": "Vytvořte účet"
},
"auth.sign-up.legal-dislaimer": {
"message": "Vytvořením účtu souhlasíte s <terms-link>Podmínkami</terms-link> a <privacy-policy-link>Zásadami ochrany osobních údajů</privacy-policy-link> Modrinthu."
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "Hesla se neshodují!"
},
"auth.sign-up.sign-in-option.title": {
"message": "Již máte účet?"
},
"auth.sign-up.subscribe.label": {
"message": "Přihlásit se k odběru informací o Modrinthu"
},
"auth.sign-up.title": {
"message": "Registrace"
},
"auth.sign-up.title.create-account": {
"message": "Nebo si vytvořte účet"
},
"auth.sign-up.title.sign-up-with": {
"message": "Registrujte se pomocí"
},
@@ -473,21 +455,6 @@
"auth.verify-email.title": {
"message": "Ověřit e-mail"
},
"auth.welcome.checkbox.subscribe": {
"message": "Přihlásit se k odběru informací o Modrinthu"
},
"auth.welcome.description": {
"message": "Nyní jste součástí úžasné komunity tvůrců a průzkumníků, kteří již vytvářejí, stahují a aktualizují úžasné módy."
},
"auth.welcome.label.tos": {
"message": "Vytvořením účtu souhlasíte s <terms-link>Podmínkami</terms-link> a <privacy-policy-link>Zásadami ochrany osobních údajů</privacy-policy-link> Modrinthu."
},
"auth.welcome.long-title": {
"message": "Vítejte v Modrinthu!"
},
"auth.welcome.title": {
"message": "Vítejte"
},
"collection.button.edit-icon": {
"message": "Upravit ikonu"
},
@@ -263,39 +263,21 @@
"auth.sign-in.2fa.placeholder": {
"message": "Indtast kode..."
},
"auth.sign-in.additional-options": {
"message": "<forgot-password-link>Glemt adgangskode?</forgot-password-link> • <create-account-link>Opret en konto</create-account-link>"
},
"auth.sign-in.sign-in-with": {
"message": "Log ind med"
},
"auth.sign-in.title": {
"message": "Log ind"
},
"auth.sign-in.use-password": {
"message": "Eller brug en adgangskode"
},
"auth.sign-up.action.create-account": {
"message": "Opret konto"
},
"auth.sign-up.legal-dislaimer": {
"message": "Ved at oprette en konto, accepterer du Modrinth's <terms-link>Vilkår</terms-link> og <privacy-policy-link>Privatlivspolitik</privacy-policy-link>."
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "Adgangskoderne er ikke ens!"
},
"auth.sign-up.sign-in-option.title": {
"message": "Har du allerede en konto?"
},
"auth.sign-up.subscribe.label": {
"message": "Abonner på opdateringer om Modrinth"
},
"auth.sign-up.title": {
"message": "Tilmeld"
},
"auth.sign-up.title.create-account": {
"message": "Eller opret en konto selv"
},
"auth.sign-up.title.sign-up-with": {
"message": "Tilmeld dig med"
},
@@ -329,21 +311,6 @@
"auth.verify-email.title": {
"message": "Bekræft E-mail"
},
"auth.welcome.checkbox.subscribe": {
"message": "Abonner på opdateringer om Modrinth"
},
"auth.welcome.description": {
"message": "Du er nu en del af det awesome fællesskab og skabere og udforskere der allerede bygger, downloader, og forbliver opdateret med fantastiske mods."
},
"auth.welcome.label.tos": {
"message": "Ved at oprette en konto, accepterer du Modrinth's <terms-link>Vilkår</terms-link> og <privacy-policy-link>Privatlivspolitik</privacy-policy-link>."
},
"auth.welcome.long-title": {
"message": "Velkommen til Modrinth!"
},
"auth.welcome.title": {
"message": "Velkommen"
},
"collection.button.edit-icon": {
"message": "Rediger ikon"
},
@@ -737,39 +737,21 @@
"auth.sign-in.2fa.placeholder": {
"message": "Code eingeben..."
},
"auth.sign-in.additional-options": {
"message": "<forgot-password-link>Passwort vergessen?</forgot-password-link> • <create-account-link>Konto erstellen</create-account-link>"
},
"auth.sign-in.sign-in-with": {
"message": "Anmelden mit"
},
"auth.sign-in.title": {
"message": "Anmelden"
},
"auth.sign-in.use-password": {
"message": "Oder verwende ein Passwort"
},
"auth.sign-up.action.create-account": {
"message": "Konto erstellen"
},
"auth.sign-up.legal-dislaimer": {
"message": "Durch das erstellen eines Kontos stimmst du den <terms-link>Nutzungsbedingungen</terms-link> und <privacy-policy-link>Datenschutzrichtlinien</privacy-policy-link> von Modrinth zu."
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "Passwörter stimmen nicht überein!"
},
"auth.sign-up.sign-in-option.title": {
"message": "Du hast bereits ein Konto?"
},
"auth.sign-up.subscribe.label": {
"message": "Folge den Updates über Modrinth"
},
"auth.sign-up.title": {
"message": "Registrieren"
},
"auth.sign-up.title.create-account": {
"message": "Oder erstelle selbst ein Konto"
},
"auth.sign-up.title.sign-up-with": {
"message": "Registrieren mit"
},
@@ -803,21 +785,6 @@
"auth.verify-email.title": {
"message": "E-Mail verifizieren"
},
"auth.welcome.checkbox.subscribe": {
"message": "Folge updates über Modrinth"
},
"auth.welcome.description": {
"message": "Du bist nun teil der grossartigen Community von Entwicklern und Entdeckern, welche bereits tolle Projekte erstellen, herunterladen und auf den neusten Stand halten."
},
"auth.welcome.label.tos": {
"message": "Durch das erstellen eines Kontos hast du den <terms-link>Nutzungsbedingungen</terms-link> und <privacy-policy-link>Datenschutzrichtlinien</privacy-policy-link> von Modrinth zugestimmt."
},
"auth.welcome.long-title": {
"message": "Willkommen bei Modrinth!"
},
"auth.welcome.title": {
"message": "Willkommen"
},
"collection.button.edit-icon": {
"message": "Icon bearbeiten"
},
@@ -737,39 +737,21 @@
"auth.sign-in.2fa.placeholder": {
"message": "Code eingeben..."
},
"auth.sign-in.additional-options": {
"message": "<forgot-password-link>Passwort vergessen?</forgot-password-link> • <create-account-link>Erstelle ein Konto</create-account-link>"
},
"auth.sign-in.sign-in-with": {
"message": "Anmelden mit"
},
"auth.sign-in.title": {
"message": "Anmelden"
},
"auth.sign-in.use-password": {
"message": "Oder Passwort verwenden"
},
"auth.sign-up.action.create-account": {
"message": "Konto erstellen"
},
"auth.sign-up.legal-dislaimer": {
"message": "Durch die Erstellung eines Kontos stimmst du den <terms-link>Nutzungsbedingungen</terms-link> und der <privacy-policy-link>Datenschutzerklärung</privacy-policy-link> von Modrinth zu."
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "Passwörter stimmen nicht überein!"
},
"auth.sign-up.sign-in-option.title": {
"message": "Du hast bereits ein Konto?"
},
"auth.sign-up.subscribe.label": {
"message": "Updates zu Modrinth abonnieren"
},
"auth.sign-up.title": {
"message": "Registrieren"
},
"auth.sign-up.title.create-account": {
"message": "Oder erstelle selbst ein Konto"
},
"auth.sign-up.title.sign-up-with": {
"message": "Registrieren mit"
},
@@ -803,21 +785,6 @@
"auth.verify-email.title": {
"message": "E-Mail bestätigen"
},
"auth.welcome.checkbox.subscribe": {
"message": "Erhalte Neuigkeiten zu Modrinth"
},
"auth.welcome.description": {
"message": "Du bist jetzt Teil der großartigen Community von Entwicklern und Entdeckern die bereits fantastische Mods erstellen, herunterladen und auf dem neuesten Stand halten."
},
"auth.welcome.label.tos": {
"message": "Durch die Erstellung eines Kontos hast du den <terms-link>Nutzungsbedingungen</terms-link> und der <privacy-policy-link>Datenschutzerklärung</privacy-policy-link> von Modrinth zugestimmt."
},
"auth.welcome.long-title": {
"message": "Willkommen bei Modrinth!"
},
"auth.welcome.title": {
"message": "Willkommen"
},
"collection.button.edit-icon": {
"message": "Icon bearbeiten"
},
+144 -33
View File
@@ -731,12 +731,72 @@
"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.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 do not meet the age requirement to create an account at Modrinth."
},
"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. We only use it during sign-up to validate COPPA compliance."
},
"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.label": {
"message": "Username"
},
"auth.create-account.username.placeholder": {
"message": "Enter username"
},
"auth.reset-password.method-choice.action": {
"message": "Send recovery email"
},
@@ -771,53 +831,68 @@
"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.continue-with-passkey": {
"message": "Continue with passkey"
},
"auth.sign-in.create-account": {
"message": "Sign up"
},
"auth.sign-in.forgot-password": {
"message": "Forgot password"
},
"auth.sign-in.last-sign-in": {
"message": "Last used"
},
"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"
},
@@ -4031,6 +4100,48 @@
"settings.account.security.email.title": {
"message": "Email"
},
"settings.account.security.passkey.add": {
"message": "Add passkey"
},
"settings.account.security.passkey.add-modal.name.description": {
"message": "Make sure to pick something memorable, so you can identify this passkey later."
},
"settings.account.security.passkey.add-modal.name.label": {
"message": "Name"
},
"settings.account.security.passkey.add-modal.name.placeholder": {
"message": "My passkey"
},
"settings.account.security.passkey.description": {
"message": "Manage your registered passkeys, or add a new one."
},
"settings.account.security.passkey.modal.added": {
"message": "Added {ago}"
},
"settings.account.security.passkey.modal.last-used": {
"message": "Last used {ago}"
},
"settings.account.security.passkey.modal.loading": {
"message": "Loading passkeys…"
},
"settings.account.security.passkey.modal.never-used": {
"message": "Never used"
},
"settings.account.security.passkey.modal.no-passkeys": {
"message": "You do not have any passkeys registered."
},
"settings.account.security.passkey.remove.description": {
"message": "This will permanently remove the passkey \"{name}\". You will no longer be able to sign in with it."
},
"settings.account.security.passkey.remove.title": {
"message": "Are you sure you want to remove this passkey?"
},
"settings.account.security.passkey.rename-modal.header": {
"message": "Rename passkey"
},
"settings.account.security.passkey.title": {
"message": "Manage passkeys"
},
"settings.account.security.password.action.add": {
"message": "Add password"
},
@@ -779,39 +779,21 @@
"auth.sign-in.2fa.placeholder": {
"message": "Introduce el código..."
},
"auth.sign-in.additional-options": {
"message": "<forgot-password-link>¿Olvidaste tu contraseña?</forgot-password-link> • <create-account-link>Crear una cuenta</create-account-link>"
},
"auth.sign-in.sign-in-with": {
"message": "Iniciar sesión con"
},
"auth.sign-in.title": {
"message": "Iniciar sesión"
},
"auth.sign-in.use-password": {
"message": "O usa una contraseña"
},
"auth.sign-up.action.create-account": {
"message": "Crear cuenta"
},
"auth.sign-up.legal-dislaimer": {
"message": "Al crear una cuenta, aceptas los <terms-link>Términos</terms-link> y la <privacy-policy-link>Política de privacidad</privacy-policy-link> de Modrinth."
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "¡Las contraseñas no coinciden!"
},
"auth.sign-up.sign-in-option.title": {
"message": "¿Ya tienes una cuenta?"
},
"auth.sign-up.subscribe.label": {
"message": "Suscríbete para recibir actualizaciones sobre Modrinth"
},
"auth.sign-up.title": {
"message": "Registrarse"
},
"auth.sign-up.title.create-account": {
"message": "O crea tu propia cuenta"
},
"auth.sign-up.title.sign-up-with": {
"message": "Registrarse con"
},
@@ -845,21 +827,6 @@
"auth.verify-email.title": {
"message": "Verificar correo electrónico"
},
"auth.welcome.checkbox.subscribe": {
"message": "Suscríbete para recibir actualizaciones sobre Modrinth"
},
"auth.welcome.description": {
"message": "Ahora formas parte de la increíble comunidad de creadores y exploradores que ya están construyendo, descargando y manteniéndose al día con mods asombrosos."
},
"auth.welcome.label.tos": {
"message": "Al crear una cuenta, has aceptado los <terms-link>Términos</terms-link> y la <privacy-policy-link>Política de privacidad</privacy-policy-link> de Modrinth."
},
"auth.welcome.long-title": {
"message": "¡Bienvenido a Modrinth!"
},
"auth.welcome.title": {
"message": "Bienvenido"
},
"collection.button.edit-icon": {
"message": "Editar ícono"
},
@@ -779,39 +779,21 @@
"auth.sign-in.2fa.placeholder": {
"message": "Introducir código..."
},
"auth.sign-in.additional-options": {
"message": "<forgot-password-link>¿Olvidaste tu contraseña?</forgot-password-link> • <create-account-link>Crear una cuenta</create-account-link>"
},
"auth.sign-in.sign-in-with": {
"message": "Iniciar sesión con"
},
"auth.sign-in.title": {
"message": "Iniciar sesión"
},
"auth.sign-in.use-password": {
"message": "O usa una contraseña"
},
"auth.sign-up.action.create-account": {
"message": "Crear cuenta"
},
"auth.sign-up.legal-dislaimer": {
"message": "Al crear una cuenta, tú aceptas los <terms-link>Términos</terms-link> y <privacy-policy-link>Política de Privacidad</privacy-policy-link> de Modrinth."
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "¡Las contraseñas no coinciden!"
},
"auth.sign-up.sign-in-option.title": {
"message": "¿Ya tienes una cuenta?"
},
"auth.sign-up.subscribe.label": {
"message": "Suscríbete para recibir actualizaciones sobre Modrinth"
},
"auth.sign-up.title": {
"message": "Registro"
},
"auth.sign-up.title.create-account": {
"message": "O crea una cuenta tú mismo"
},
"auth.sign-up.title.sign-up-with": {
"message": "Registrarse con"
},
@@ -845,21 +827,6 @@
"auth.verify-email.title": {
"message": "Verificar correo electrónico"
},
"auth.welcome.checkbox.subscribe": {
"message": "Suscríbete a las novedades sobre Modrinth"
},
"auth.welcome.description": {
"message": "Ahora formas parte de la increíble comunidad de creadores y exploradores que ya están creando, descargando y manteniéndose al día con mods increíbles."
},
"auth.welcome.label.tos": {
"message": "Al crear esta cuenta, aceptas los <terms-link>términos de Modrinth</terms-link> y la <privacy-policy-link>Política de Privacidad</privacy-policy-link>."
},
"auth.welcome.long-title": {
"message": "¡Bienvenido a Modrinth!"
},
"auth.welcome.title": {
"message": "Bienvenido"
},
"collection.button.edit-icon": {
"message": "Editar icono"
},
@@ -269,30 +269,15 @@
"auth.sign-in.title": {
"message": "Kirjaudu sisään"
},
"auth.sign-in.use-password": {
"message": "Tai käytä salasanaa"
},
"auth.sign-up.action.create-account": {
"message": "Luo tili"
},
"auth.sign-up.legal-dislaimer": {
"message": "Luomalla tilin, hyväksyt Modrinthin <terms-link>-käyttöoikeussopimuksen</terms-link> ja <privacy-policy-link>Tietosuojakäytännön</privacy-policy-link>."
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "Salasanat eivät täsmää!"
},
"auth.sign-up.sign-in-option.title": {
"message": "Onko sinulla jo käyttäjätili?"
},
"auth.sign-up.subscribe.label": {
"message": "Tilaa päivityksiä Modrinthista"
},
"auth.sign-up.title": {
"message": "Rekisteröidy"
},
"auth.sign-up.title.create-account": {
"message": "Tai luo tili itse"
},
"auth.sign-up.title.sign-up-with": {
"message": "Reskiteröidy käyttäen"
},
@@ -326,18 +311,6 @@
"auth.verify-email.title": {
"message": "Vahvista sähköposti"
},
"auth.welcome.checkbox.subscribe": {
"message": "Tilaa päivityksiä Modrinthista"
},
"auth.welcome.label.tos": {
"message": "Luomalla tilin, olet hyväksynyt Modrinthin <terms-link>-käyttöoikeussopimuksen</terms-link> ja <privacy-policy-link>Tietosuojakäytännön</privacy-policy-link>."
},
"auth.welcome.long-title": {
"message": "Tervetuloa Modrinthiin!"
},
"auth.welcome.title": {
"message": "Tervetuloa"
},
"collection.button.edit-icon": {
"message": "Muokkaa kuvaketta"
},
@@ -395,39 +395,21 @@
"auth.sign-in.2fa.placeholder": {
"message": "Ilagay ang code..."
},
"auth.sign-in.additional-options": {
"message": "<forgot-password-link>Nalimutan ang password?</forgot-password-link> • <create-account-link>Lumikha ng account</create-account-link>"
},
"auth.sign-in.sign-in-with": {
"message": "Mag-sign in gamit ang"
},
"auth.sign-in.title": {
"message": "Mag-sign In"
},
"auth.sign-in.use-password": {
"message": "O gamitin ang password"
},
"auth.sign-up.action.create-account": {
"message": "Lumikha ng account"
},
"auth.sign-up.legal-dislaimer": {
"message": "Sa paggawa ng account, sumasang-ayon ka sa <terms-link>Mga Tuntunin</terms-link> at <privacy-policy-link>Patakaran sa Pribasiya</privacy-policy-link> ng Modrinth."
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "Hindi magkatugma ang mga password!"
},
"auth.sign-up.sign-in-option.title": {
"message": "Mayroon ka nang account?"
},
"auth.sign-up.subscribe.label": {
"message": "Mag-subscribe ukol sa mga update sa Modrinth"
},
"auth.sign-up.title": {
"message": "Mag-sign Up"
},
"auth.sign-up.title.create-account": {
"message": "O sariling lumkiha account"
},
"auth.sign-up.title.sign-up-with": {
"message": "Mag-sign up gamit ang"
},
@@ -461,21 +443,6 @@
"auth.verify-email.title": {
"message": "Patunayan ang Email"
},
"auth.welcome.checkbox.subscribe": {
"message": "Mag-subscribe ukol sa mga update ng Modrinth"
},
"auth.welcome.description": {
"message": "Ikaw ay parte na ngayon ng kahanga-hangang komunidad na ito ng mga tagagawa at manlalalakbay na nagtataguyod, nagda-download, at nananatiling napapanahon sa mga nakamamanghang na mga mod."
},
"auth.welcome.label.tos": {
"message": "Sa paggawa ng account, sumang-ayon ka na sa <terms-link>Mga Tuntunin</terms-link> at <privacy-policy-link>Patakaran sa Pribasiya</privacy-policy-link> ng Modrinth."
},
"auth.welcome.long-title": {
"message": "Maligayang pagdating sa Modrinth!"
},
"auth.welcome.title": {
"message": "Maligayang Pagdating"
},
"collection.button.edit-icon": {
"message": "Baguhin ang ikono"
},
@@ -779,39 +779,21 @@
"auth.sign-in.2fa.placeholder": {
"message": "Saisir le code..."
},
"auth.sign-in.additional-options": {
"message": "<forgot-password-link>Mot de passe oublié ?</forgot-password-link> • <create-account-link>Créer un compte</create-account-link>"
},
"auth.sign-in.sign-in-with": {
"message": "Se connecter avec"
},
"auth.sign-in.title": {
"message": "Se connecter"
},
"auth.sign-in.use-password": {
"message": "Ou utiliser un mot de passe"
},
"auth.sign-up.action.create-account": {
"message": "Créer un compte"
},
"auth.sign-up.legal-dislaimer": {
"message": "En créant un compte, vous acceptez les <terms-link>Conditions d'utilisation</terms-link> et la <privacy-policy-link>Politique de confidentialité</privacy-policy-link> de Modrinth."
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "Les mots de passe ne correspondent pas !"
},
"auth.sign-up.sign-in-option.title": {
"message": "Vous avez déjà un compte ?"
},
"auth.sign-up.subscribe.label": {
"message": "S'abonner aux mises à jour de Modrinth"
},
"auth.sign-up.title": {
"message": "S'inscrire"
},
"auth.sign-up.title.create-account": {
"message": "Ou créez vous-même un compte"
},
"auth.sign-up.title.sign-up-with": {
"message": "S'inscrire avec"
},
@@ -845,21 +827,6 @@
"auth.verify-email.title": {
"message": "Vérifier l'adresse e-mail"
},
"auth.welcome.checkbox.subscribe": {
"message": "S'abonner aux mises à jour de Modrinth"
},
"auth.welcome.description": {
"message": "Vous faites maintenant partie de la formidable communauté de créateurs et d'explorateurs qui construisent, téléchargent et se tiennent au courant des incroyables mods."
},
"auth.welcome.label.tos": {
"message": "En créant un compte, vous acceptez les <terms-link>Conditions d'utilisation</terms-link> et la <privacy-policy-link>Politique de confidentialité</privacy-policy-link> de Modrinth."
},
"auth.welcome.long-title": {
"message": "Bienvenue sur Modrinth !"
},
"auth.welcome.title": {
"message": "Bienvenue"
},
"collection.button.edit-icon": {
"message": "Modifier l'icône"
},
@@ -263,39 +263,21 @@
"auth.sign-in.2fa.placeholder": {
"message": "כתבו קוד..."
},
"auth.sign-in.additional-options": {
"message": "<forgot-password-link>שככו את הסיסמה?</forgot-password-link>\n<create-account-link>ליצור חשבון</create-account-link>"
},
"auth.sign-in.sign-in-with": {
"message": "הכנס אם"
},
"auth.sign-in.title": {
"message": "הכנס"
},
"auth.sign-in.use-password": {
"message": "או להשתמש בסיסמה"
},
"auth.sign-up.action.create-account": {
"message": "ליצור חשבון"
},
"auth.sign-up.legal-dislaimer": {
"message": "כליצור את החשבון, אתם מסכימים ל<terms-link>תנאים</terms-link> ו<privacy-policy-link>מדיניות פרטיות</privacy-policy-link> של Modrinth."
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "סיסמאות לא תואמות!"
},
"auth.sign-up.sign-in-option.title": {
"message": "כבר יש לכם חשבון?"
},
"auth.sign-up.subscribe.label": {
"message": "להרשם לעדכונים על Modrinth"
},
"auth.sign-up.title": {
"message": "הרשם"
},
"auth.sign-up.title.create-account": {
"message": "או עצרו חשבון בעצמכם"
},
"auth.sign-up.title.sign-up-with": {
"message": "הרשם אם"
},
@@ -329,21 +311,6 @@
"auth.verify-email.title": {
"message": "אמת אימייל"
},
"auth.welcome.checkbox.subscribe": {
"message": "הירשם לעדכונים על Modrinth"
},
"auth.welcome.description": {
"message": "אתה עכשיו חלק מהקהילה המדהימה של יוצרים ומשתמשים שכבר בונים, מורידים ומתעדכנים עם מודים מגניבים."
},
"auth.welcome.label.tos": {
"message": "ע\"י יצירת חשבון, אתה הסכמת ל<terms-link>תנאים</terms-link> של מודרינפ ו<privacy-policy-link>מדיניות הפרטיות</privacy-policy-link>."
},
"auth.welcome.long-title": {
"message": "ברוכים הבאים ל-Modrinth!"
},
"auth.welcome.title": {
"message": "ברוכים הבאים"
},
"collection.button.edit-icon": {
"message": "ערוך סמל"
},
@@ -689,39 +689,21 @@
"auth.sign-in.2fa.placeholder": {
"message": "Kód beírása..."
},
"auth.sign-in.additional-options": {
"message": "<forgot-password-link>Elfelejtetted a jelszavad?</forgot-password-link> • <create-account-link>Hozz létre egy fiókot</create-account-link>"
},
"auth.sign-in.sign-in-with": {
"message": "Bejelentkezés ezzel"
},
"auth.sign-in.title": {
"message": "Bejelentkezés"
},
"auth.sign-in.use-password": {
"message": "Vagy használj jelszót"
},
"auth.sign-up.action.create-account": {
"message": "Fiók létrehozása"
},
"auth.sign-up.legal-dislaimer": {
"message": "Fiók létrehozásával elfogadod a Modrinth <terms-link>Felhasználási feltételeit</terms-link> és <privacy-policy-link>Adatvédelmi irányelveit</privacy-policy-link>."
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "A jelszavak nem jegyeznek!"
},
"auth.sign-up.sign-in-option.title": {
"message": "Már van fiókod?"
},
"auth.sign-up.subscribe.label": {
"message": "Feliratkozás a Modrinth híreire és frissítéseire"
},
"auth.sign-up.title": {
"message": "Regisztráció"
},
"auth.sign-up.title.create-account": {
"message": "Vagy hozz létre egyet magadnak"
},
"auth.sign-up.title.sign-up-with": {
"message": "Regisztráció ezzel"
},
@@ -755,21 +737,6 @@
"auth.verify-email.title": {
"message": "Email-cím hitelesítése"
},
"auth.welcome.checkbox.subscribe": {
"message": "Feliratkozás a Modrinth híreire és frissítéseire"
},
"auth.welcome.description": {
"message": "Most már része vagy annak a fantasztikus fejlesztők és felfedezők közösségének, akik már építenek, letöltenek és naprakészek maradnak a csodálatos modokkal kapcsolatban."
},
"auth.welcome.label.tos": {
"message": "Fiók létrehozásával elfogadod a Modrinth <terms-link>Felhasználási feltételeit</terms-link> és <privacy-policy-link>Adatvédelmi irányelveit</privacy-policy-link>."
},
"auth.welcome.long-title": {
"message": "Üdvözlünk a Modrinthon!"
},
"auth.welcome.title": {
"message": "Üdvözlünk"
},
"collection.button.edit-icon": {
"message": "Ikon szerkesztése"
},
@@ -482,39 +482,21 @@
"auth.sign-in.2fa.placeholder": {
"message": "Masukkan kode..."
},
"auth.sign-in.additional-options": {
"message": "<forgot-password-link>Lupa kata sandi?</forgot-password-link> • <create-account-link>Buat akun</create-account-link>"
},
"auth.sign-in.sign-in-with": {
"message": "Masuk dengan"
},
"auth.sign-in.title": {
"message": "Masuk"
},
"auth.sign-in.use-password": {
"message": "Atau gunakan kata sandi"
},
"auth.sign-up.action.create-account": {
"message": "Buat akun"
},
"auth.sign-up.legal-dislaimer": {
"message": "Dengan membuat sebuah akun, Anda menyetujui <terms-link>Ketentuan</terms-link> dan <privacy-policy-link>Kebijakan Privasi</privacy-policy-link> Modrinth."
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "Kata sandi tidak cocok!"
},
"auth.sign-up.sign-in-option.title": {
"message": "Sudah memiliki akun?"
},
"auth.sign-up.subscribe.label": {
"message": "Berlangganan pembaruan tentang Modrinth"
},
"auth.sign-up.title": {
"message": "Daftar"
},
"auth.sign-up.title.create-account": {
"message": "Atau buat akun Anda sendiri"
},
"auth.sign-up.title.sign-up-with": {
"message": "Daftar dengan"
},
@@ -548,21 +530,6 @@
"auth.verify-email.title": {
"message": "Verifikasi Sur-el"
},
"auth.welcome.checkbox.subscribe": {
"message": "Berlangganan pembaruan tentang Modrinth"
},
"auth.welcome.description": {
"message": "Anda kini menjadi bagian dari komunitas kreator dan penjelajah hebat yang sudah membangun, mengunduh, dan terus mengikuti perkembangan terkini dari mod-mod yang luar biasa."
},
"auth.welcome.label.tos": {
"message": "Dengan membuat sebuah akun, Anda telah menyetujui <terms-link>Ketentuan</terms-link> dan <privacy-policy-link>Kebijakan Privasi</privacy-policy-link> Modrinth."
},
"auth.welcome.long-title": {
"message": "Selamat datang di Modrinth!"
},
"auth.welcome.title": {
"message": "Selamat Datang"
},
"collection.button.edit-icon": {
"message": "Sunting ikon"
},
@@ -773,39 +773,21 @@
"auth.sign-in.2fa.placeholder": {
"message": "Inserisci il codice..."
},
"auth.sign-in.additional-options": {
"message": "<forgot-password-link>Password dimenticata?</forgot-password-link> • <create-account-link>Crea un account</create-account-link>"
},
"auth.sign-in.sign-in-with": {
"message": "Accedi con"
},
"auth.sign-in.title": {
"message": "Accedi"
},
"auth.sign-in.use-password": {
"message": "O usa la password"
},
"auth.sign-up.action.create-account": {
"message": "Crea account"
},
"auth.sign-up.legal-dislaimer": {
"message": "Creando un''account, accetti i <terms-link>termini d''uso</terms-link> e l''<privacy-policy-link>informativa sulla privacy</privacy-policy-link> di Modrinth."
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "Le password non corrispondono!"
},
"auth.sign-up.sign-in-option.title": {
"message": "Hai già un account?"
},
"auth.sign-up.subscribe.label": {
"message": "Ricevi novità da Modrinth"
},
"auth.sign-up.title": {
"message": "Registrati"
},
"auth.sign-up.title.create-account": {
"message": "O crea un account"
},
"auth.sign-up.title.sign-up-with": {
"message": "Accedi con"
},
@@ -839,21 +821,6 @@
"auth.verify-email.title": {
"message": "Verifica email"
},
"auth.welcome.checkbox.subscribe": {
"message": "Ricevi novità riguardanti Modrinth"
},
"auth.welcome.description": {
"message": "Fai ora parte della grandiosa comunità di creatori ed esploratori che già costruiscono, aggiornano e stanno sul pezzo con le fantastiche mod."
},
"auth.welcome.label.tos": {
"message": "Creando un''account, hai accettato i <terms-link>termini d''utilizzo</terms-link> e l''<privacy-policy-link>informativa sulla privacy</privacy-policy-link> di Modrinth."
},
"auth.welcome.long-title": {
"message": "Modrinth ti dà il benvenuto!"
},
"auth.welcome.title": {
"message": "Ciao"
},
"collection.button.edit-icon": {
"message": "Modifica icona"
},
@@ -539,39 +539,21 @@
"auth.sign-in.2fa.placeholder": {
"message": "コードを入力…"
},
"auth.sign-in.additional-options": {
"message": "<forgot-password-link>パスワードをお忘れですか?</forgot-password-link> • <create-account-link>アカウントを作成する</create-account-link>"
},
"auth.sign-in.sign-in-with": {
"message": "外部アカウントでログイン"
},
"auth.sign-in.title": {
"message": "ログイン"
},
"auth.sign-in.use-password": {
"message": "パスワードでログイン"
},
"auth.sign-up.action.create-account": {
"message": "アカウントを作成"
},
"auth.sign-up.legal-dislaimer": {
"message": "アカウントを作成すると、Modrinthの <terms-link>利用規約</terms-link> および <privacy-policy-link>プライバシーポリシー</privacy-policy-link>に同意したものとみなされます。"
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "パスワードが一致しません!"
},
"auth.sign-up.sign-in-option.title": {
"message": "すでにアカウントをお持ちですか?"
},
"auth.sign-up.subscribe.label": {
"message": "Modrinthのアップデートを受け取る"
},
"auth.sign-up.title": {
"message": "アカウント作成"
},
"auth.sign-up.title.create-account": {
"message": "メールアドレスで作成"
},
"auth.sign-up.title.sign-up-with": {
"message": "外部アカウントで作成"
},
@@ -605,21 +587,6 @@
"auth.verify-email.title": {
"message": "メールアドレスを確認"
},
"auth.welcome.checkbox.subscribe": {
"message": "Modrinthのアップデートを受け取る"
},
"auth.welcome.description": {
"message": "素晴らしいModの開発や導入、アップデートを日々行っている、クリエイターと冒険者たちの活気あるコミュニティへようこそ。"
},
"auth.welcome.label.tos": {
"message": "アカウントを作成すると、Modrinthの <terms-link>利用規約</terms-link> および <privacy-policy-link>プライバシーポリシー</privacy-policy-link>に同意したものとみなされます。"
},
"auth.welcome.long-title": {
"message": "Modrinthへようこそ!"
},
"auth.welcome.title": {
"message": "ようこそ"
},
"collection.button.edit-icon": {
"message": "アイコンを編集"
},
@@ -275,39 +275,21 @@
"auth.sign-in.2fa.placeholder": {
"message": "코드 입력..."
},
"auth.sign-in.additional-options": {
"message": "<forgot-password-link>비밀번호를 잊으셨나요?</forgot-password-link> • <create-account-link>계정 만들기</create-account-link>"
},
"auth.sign-in.sign-in-with": {
"message": "다음을 통해 로그인"
},
"auth.sign-in.title": {
"message": "로그인"
},
"auth.sign-in.use-password": {
"message": "또는 비밀번호 사용하기"
},
"auth.sign-up.action.create-account": {
"message": "계정 생성"
},
"auth.sign-up.legal-dislaimer": {
"message": "계정을 생성함으로써, Modrinth의 <terms-link>이용 약관</terms-link> 및 <privacy-policy-link>개인정보 보호정책</privacy-policy-link>에 동의합니다."
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "비밀번호가 일치하지 않습니다!"
},
"auth.sign-up.sign-in-option.title": {
"message": "이미 계정이 있으신가요?"
},
"auth.sign-up.subscribe.label": {
"message": "Modrinth의 업데이트 소식 전달받기"
},
"auth.sign-up.title": {
"message": "회원 가입"
},
"auth.sign-up.title.create-account": {
"message": "또는 직접 계정을 만드세요"
},
"auth.sign-up.title.sign-up-with": {
"message": "다음 계정으로 가입"
},
@@ -341,21 +323,6 @@
"auth.verify-email.title": {
"message": "이메일 인증"
},
"auth.welcome.checkbox.subscribe": {
"message": "Modrinth의 업데이트 소식 전달받기"
},
"auth.welcome.description": {
"message": "이제 당신은 놀라운 모드를 만들고, 다운로드하고, 최신 상태로 유지하는 훌륭한 크리에이터 및 탐험가들의 모임의 일원이 되었습니다."
},
"auth.welcome.label.tos": {
"message": "계정을 생성함으로써, Modrinth의 <terms-link>이용 약관</terms-link> 및 <privacy-policy-link>개인정보 보호정책</privacy-policy-link>에 동의하게 됩니다."
},
"auth.welcome.long-title": {
"message": "Modrinth에 오신 걸 환영합니다!"
},
"auth.welcome.title": {
"message": "환영합니다"
},
"collection.button.edit-icon": {
"message": "아이콘 수정"
},
@@ -665,39 +665,21 @@
"auth.sign-in.2fa.placeholder": {
"message": "Masukkan kod..."
},
"auth.sign-in.additional-options": {
"message": "<forgot-password-link>Lupa kata laluan?</forgot-password-link> • <create-account-link>Cipta akaun</create-account-link>"
},
"auth.sign-in.sign-in-with": {
"message": "Daftar masuk dengan"
},
"auth.sign-in.title": {
"message": "Daftar Masuk"
},
"auth.sign-in.use-password": {
"message": "Atau gunakan kata laluan"
},
"auth.sign-up.action.create-account": {
"message": "Cipta akaun"
},
"auth.sign-up.legal-dislaimer": {
"message": "Dengan mencipta akaun, anda bersetuju dengan <terms-link>Terma</terms-link> dan <privacy-policy-link>Dasar Privasi</privacy-policy-link> Modrinth."
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "Kata laluan tidak sepadan!"
},
"auth.sign-up.sign-in-option.title": {
"message": "Sudah mempunyai akaun?"
},
"auth.sign-up.subscribe.label": {
"message": "Langgan kepada kemas kini tentang Modrinth"
},
"auth.sign-up.title": {
"message": "Daftar"
},
"auth.sign-up.title.create-account": {
"message": "Atau cipta akaun anda sendiri"
},
"auth.sign-up.title.sign-up-with": {
"message": "Daftar dengan"
},
@@ -731,21 +713,6 @@
"auth.verify-email.title": {
"message": "Sahkan E-mel"
},
"auth.welcome.checkbox.subscribe": {
"message": "Langgan kepada kemas kini tentang Modrinth"
},
"auth.welcome.description": {
"message": "Anda kini sebahagian daripada komuniti hebat pencipta & peneroka yang sudah membina, memuat turun dan sentiasa mengikuti perkembangan terkini mengenai mod yang menakjubkan."
},
"auth.welcome.label.tos": {
"message": "Dengan mencipta akaun, anda telah bersetuju dengan <terms-link>Terma</terms-link> dan <privacy-policy-link>Dasar Privasi</privacy-policy-link> Modrinth."
},
"auth.welcome.long-title": {
"message": "Selamat Datang ke Modrinth!"
},
"auth.welcome.title": {
"message": "Selamat Datang"
},
"collection.button.edit-icon": {
"message": "Sunting ikon"
},
@@ -626,36 +626,18 @@
"auth.sign-in.2fa.placeholder": {
"message": "Voer code in..."
},
"auth.sign-in.additional-options": {
"message": "<forgot-password-link>Wachtwoord vergeten?</forgot-password-link> • <create-account-link>Maak een account aan</create-account-link>"
},
"auth.sign-in.sign-in-with": {
"message": "Log in met"
},
"auth.sign-in.title": {
"message": "Log in"
},
"auth.sign-in.use-password": {
"message": "Of gebruik een wachtwoord"
},
"auth.sign-up.action.create-account": {
"message": "Account aanmaken"
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "Wachtwoorden komen niet overeen!"
},
"auth.sign-up.sign-in-option.title": {
"message": "Heb je al een account?"
},
"auth.sign-up.subscribe.label": {
"message": "Schrijf je in voor updates van Modrinth"
},
"auth.sign-up.title": {
"message": "Aanmelden"
},
"auth.sign-up.title.create-account": {
"message": "Of maak zelf een account"
},
"auth.sign-up.title.sign-up-with": {
"message": "Meld je aan met"
},
@@ -689,18 +671,6 @@
"auth.verify-email.title": {
"message": "Verifieer e-mail"
},
"auth.welcome.checkbox.subscribe": {
"message": "Abonneer op updates over Modrinth"
},
"auth.welcome.description": {
"message": "Je bent nu deel van de geweldige community van makers en ontdekkers die mods bouwen, downloaden en up-to-date blijven van geweldige mods."
},
"auth.welcome.long-title": {
"message": "Welkom op Modrinth!"
},
"auth.welcome.title": {
"message": "Welkom"
},
"collection.button.edit-icon": {
"message": "Bewerk icoon"
},
@@ -263,39 +263,21 @@
"auth.sign-in.2fa.placeholder": {
"message": "Skriv inn kode..."
},
"auth.sign-in.additional-options": {
"message": "<forgot-password-link>Glemt passord?</forgot-password-link> • <create-account-link>Opprett en konto</create-account-link>"
},
"auth.sign-in.sign-in-with": {
"message": "Logg inn med"
},
"auth.sign-in.title": {
"message": "Logg inn"
},
"auth.sign-in.use-password": {
"message": "Eller bruk et passord"
},
"auth.sign-up.action.create-account": {
"message": "Opprett konto"
},
"auth.sign-up.legal-dislaimer": {
"message": "Ved å opprette en konto godtar du Modrinths <terms-link>Vilkår</terms-link> og <privacy-policy-link>Personvernpolicy</privacy-policy-link>."
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "Passordene stemmer ikke overens!"
},
"auth.sign-up.sign-in-option.title": {
"message": "Har du allerede en konto?"
},
"auth.sign-up.subscribe.label": {
"message": "Abonner på oppdateringer om Modrinth"
},
"auth.sign-up.title": {
"message": "Registrer deg"
},
"auth.sign-up.title.create-account": {
"message": "Eller lag en konko selv"
},
"auth.sign-up.title.sign-up-with": {
"message": "Registrer deg med"
},
@@ -329,21 +311,6 @@
"auth.verify-email.title": {
"message": "Verifiser Email"
},
"auth.welcome.checkbox.subscribe": {
"message": "Abonner på oppdateringer om Modrinth"
},
"auth.welcome.description": {
"message": "Nå er du med i det fantastiske fellesskapet av skapere og utforskere som bygger, laster ned og holder seg oppdatert på fantastiske mods."
},
"auth.welcome.label.tos": {
"message": "Ved å opprette en konto godtar du Modrinths <terms-link>Vilkår</terms-link> og <privacy-policy-link>Personvernpolicy</privacy-policy-link>."
},
"auth.welcome.long-title": {
"message": "Velkommen til Modrinth!"
},
"auth.welcome.title": {
"message": "Velkommen"
},
"collection.button.edit-icon": {
"message": "Rediger ikon"
},
@@ -779,39 +779,21 @@
"auth.sign-in.2fa.placeholder": {
"message": "Wprowadź kod..."
},
"auth.sign-in.additional-options": {
"message": "<forgot-password-link>Zapomniałeś hasła?</forgot-password-link> • <create-account-link>Utwórz konto</create-account-link>"
},
"auth.sign-in.sign-in-with": {
"message": "Zaloguj się przez"
},
"auth.sign-in.title": {
"message": "Zaloguj się"
},
"auth.sign-in.use-password": {
"message": "Lub użyj hasła"
},
"auth.sign-up.action.create-account": {
"message": "Utwórz konto"
},
"auth.sign-up.legal-dislaimer": {
"message": "Tworząc konto, wyrażasz zgodę na <terms-link>Warunki</terms-link> i <privacy-policy-link>Politykę Prywatności</privacy-policy-link> Modrinth."
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "Hasła się różnią!"
},
"auth.sign-up.sign-in-option.title": {
"message": "Masz już konto?"
},
"auth.sign-up.subscribe.label": {
"message": "Otrzymuj aktualności o Modrinth"
},
"auth.sign-up.title": {
"message": "Zarejestruj się"
},
"auth.sign-up.title.create-account": {
"message": "Lub utwórz własne konto"
},
"auth.sign-up.title.sign-up-with": {
"message": "Zarejestruj się przez"
},
@@ -845,21 +827,6 @@
"auth.verify-email.title": {
"message": "Zweryfikuj e-mail"
},
"auth.welcome.checkbox.subscribe": {
"message": "Otrzymuj aktualności o Modrinth"
},
"auth.welcome.description": {
"message": "Teraz jesteś częścią wspaniałej społeczności twórców i odkrywców, którzy już tworzą, pobierają i na bieżąco korzystają z niesamowitych modów."
},
"auth.welcome.label.tos": {
"message": "Przez stworzenie konta, zgadzasz się na nasze<terms-link>Warunki</terms-link> oraz <privacy-policy-link>Politykę prywatności</privacy-policy-link>."
},
"auth.welcome.long-title": {
"message": "Witaj w Modrinth!"
},
"auth.welcome.title": {
"message": "Witaj"
},
"collection.button.edit-icon": {
"message": "Edytuj ikonę"
},
@@ -779,39 +779,21 @@
"auth.sign-in.2fa.placeholder": {
"message": "Insira o código..."
},
"auth.sign-in.additional-options": {
"message": "<forgot-password-link>Esqueceu sua senha?</forgot-password-link> • <create-account-link>Criar uma conta</create-account-link>"
},
"auth.sign-in.sign-in-with": {
"message": "Iniciar sessão com"
},
"auth.sign-in.title": {
"message": "Iniciar sessão"
},
"auth.sign-in.use-password": {
"message": "Ou use a senha"
},
"auth.sign-up.action.create-account": {
"message": "Criar conta"
},
"auth.sign-up.legal-dislaimer": {
"message": "Ao criar uma conta, você concorda com os <terms-link>Termos</terms-link> e a <privacy-policy-link>Política de Privacidade</privacy-policy-link>."
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "As senhas não correspondem!"
},
"auth.sign-up.sign-in-option.title": {
"message": "Já possui uma conta?"
},
"auth.sign-up.subscribe.label": {
"message": "Inscreva-se para receber novidades do Modrinth"
},
"auth.sign-up.title": {
"message": "Criar conta"
},
"auth.sign-up.title.create-account": {
"message": "Ou crie uma conta você mesmo"
},
"auth.sign-up.title.sign-up-with": {
"message": "Criar conta com"
},
@@ -845,21 +827,6 @@
"auth.verify-email.title": {
"message": "Verificar e-mail"
},
"auth.welcome.checkbox.subscribe": {
"message": "Inscreva-se para receber atualizações sobre o Modrinth"
},
"auth.welcome.description": {
"message": "Agora você faz parte da incrível comunidade de criadores e exploradores que já estão criando, baixando e acompanhando mods incríveis."
},
"auth.welcome.label.tos": {
"message": "Ao criar uma conta, você concorda com os <terms-link>Termos</terms-link> e a <privacy-policy-link>Política de Privacidade</privacy-policy-link> do Modrinth."
},
"auth.welcome.long-title": {
"message": "Bem-vindo(a) ao Modrinth!"
},
"auth.welcome.title": {
"message": "Bem-vindo"
},
"collection.button.edit-icon": {
"message": "Editar ícone"
},
@@ -263,39 +263,21 @@
"auth.sign-in.2fa.placeholder": {
"message": "Inserir código..."
},
"auth.sign-in.additional-options": {
"message": "<forgot-password-link>Esqueceste-te da palavra-passe?</forgot-password-link> • <create-account-link>Criar uma conta</create-account-link>"
},
"auth.sign-in.sign-in-with": {
"message": "Iniciar sessão com"
},
"auth.sign-in.title": {
"message": "Iniciar sessão"
},
"auth.sign-in.use-password": {
"message": "Ou utiliza uma palavra-passe"
},
"auth.sign-up.action.create-account": {
"message": "Criar conta"
},
"auth.sign-up.legal-dislaimer": {
"message": "Ao criares uma conta, concordas com os <terms-link>Termos</terms-link> e com a <privacy-policy-link>Política de Privacidade </privacy-policy-link> do Modrinth."
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "As palavras-passe não coincidem!"
},
"auth.sign-up.sign-in-option.title": {
"message": "Já tens uma conta?"
},
"auth.sign-up.subscribe.label": {
"message": "Receber novidades sobre o Modrinth por e-mail"
},
"auth.sign-up.title": {
"message": "Criar conta"
},
"auth.sign-up.title.create-account": {
"message": "Ou cria uma conta tu mesmo"
},
"auth.sign-up.title.sign-up-with": {
"message": "Criar conta com"
},
@@ -329,21 +311,6 @@
"auth.verify-email.title": {
"message": "Verificar E-mail"
},
"auth.welcome.checkbox.subscribe": {
"message": "Receber novidades sobre o Modrinth por e-mail"
},
"auth.welcome.description": {
"message": "Agora fazes parte da incrível comunidade de criadores e exploradores que criam, transferem e se mantêm atualizados com mods fantásticos."
},
"auth.welcome.label.tos": {
"message": "Ao criares uma conta, concordas com os <terms-link>Termos</terms-link> e com a <privacy-policy-link>Política de Privacidade </privacy-policy-link> do Modrinth."
},
"auth.welcome.long-title": {
"message": "Bem-vindo(a) ao Modrinth!"
},
"auth.welcome.title": {
"message": "Boas-vindas"
},
"collection.button.edit-icon": {
"message": "Editar ícone"
},
@@ -257,39 +257,21 @@
"auth.sign-in.2fa.placeholder": {
"message": "Introdu codul..."
},
"auth.sign-in.additional-options": {
"message": "<forgot-password-link>Ați uitat parola?</forgot-password-link> • <create-account-link>Creați un cont</create-account-link>"
},
"auth.sign-in.sign-in-with": {
"message": "Conectează-te cu"
},
"auth.sign-in.title": {
"message": "Conectare"
},
"auth.sign-in.use-password": {
"message": "Sau folosiți o parolă"
},
"auth.sign-up.action.create-account": {
"message": "Creare cont"
},
"auth.sign-up.legal-dislaimer": {
"message": "Prin crearea unui cont, ești de acord cu <terms-link>Termenii</terms-link> și <privacy-policy-link>Politica de confidențialitate</privacy-policy-link> Modrinth."
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "Parolele nu se potrivesc!"
},
"auth.sign-up.sign-in-option.title": {
"message": "Aveți deja un cont?"
},
"auth.sign-up.subscribe.label": {
"message": "Abonați-vă la actualizările despre Modrinth"
},
"auth.sign-up.title": {
"message": "Înregistrare"
},
"auth.sign-up.title.create-account": {
"message": "Sau creează-ți singur un cont"
},
"auth.sign-up.title.sign-up-with": {
"message": "Înregistrare cu"
},
@@ -323,21 +305,6 @@
"auth.verify-email.title": {
"message": "Verifică email-ul"
},
"auth.welcome.checkbox.subscribe": {
"message": "Abonați-vă la actualizările despre Modrinth"
},
"auth.welcome.description": {
"message": "Acum faci parte din minunata comunitate de creatori și exploratori care deja creează, descarcă și rămân la curent cu moduri extraordinare."
},
"auth.welcome.label.tos": {
"message": "Prin crearea unui cont, ești de acord cu <terms-link>Termenii</terms-link> și <privacy-policy-link>Politica de confidențialitate</privacy-policy-link> Modrinth."
},
"auth.welcome.long-title": {
"message": "Bine ați venit pe Modrinth!"
},
"auth.welcome.title": {
"message": "Bine ați venit"
},
"collection.button.edit-icon": {
"message": "Editează pictograma"
},
@@ -761,39 +761,21 @@
"auth.sign-in.2fa.placeholder": {
"message": "Введите код..."
},
"auth.sign-in.additional-options": {
"message": "<forgot-password-link>Забыли пароль?</forgot-password-link> • <create-account-link>Создать аккаунт</create-account-link>"
},
"auth.sign-in.sign-in-with": {
"message": "Войдите через"
},
"auth.sign-in.title": {
"message": "Вход"
},
"auth.sign-in.use-password": {
"message": "Или используйте пароль"
},
"auth.sign-up.action.create-account": {
"message": "Создать аккаунт"
},
"auth.sign-up.legal-dislaimer": {
"message": "Регистрируясь, вы принимаете наши <terms-link>условия</terms-link> и <privacy-policy-link>политику конфиденци­аль­ности</privacy-policy-link>."
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "Пароли не совпадают!"
},
"auth.sign-up.sign-in-option.title": {
"message": "Уже есть аккаунт?"
},
"auth.sign-up.subscribe.label": {
"message": "Получать новости о Modrinth"
},
"auth.sign-up.title": {
"message": "Регистрация"
},
"auth.sign-up.title.create-account": {
"message": "Или создайте аккаунт сами"
},
"auth.sign-up.title.sign-up-with": {
"message": "Зарегистрируйтесь через"
},
@@ -827,21 +809,6 @@
"auth.verify-email.title": {
"message": "Подтверждение почты"
},
"auth.welcome.checkbox.subscribe": {
"message": "Получать новости о Modrinth"
},
"auth.welcome.description": {
"message": "Теперь вы часть потрясающего сообщества авторов и игроков, которые вместе создают, находят и следят за любимыми модами."
},
"auth.welcome.label.tos": {
"message": "Зарегистрировавшись, вы приняли наши <terms-link>условия</terms-link> и <privacy-policy-link>политику конфиденци­аль­ности</privacy-policy-link>."
},
"auth.welcome.long-title": {
"message": "Добро пожаловать!"
},
"auth.welcome.title": {
"message": "Добро пожаловать"
},
"collection.button.edit-icon": {
"message": "Изменить иконку"
},
@@ -122,39 +122,21 @@
"auth.sign-in.2fa.placeholder": {
"message": "Unesite kod..."
},
"auth.sign-in.additional-options": {
"message": "<forgot-password-link>Zaboravili ste lozinku?</forgot-password-link> • <create-account-link>Kreirajte nalog</create-account-link>"
},
"auth.sign-in.sign-in-with": {
"message": "Prijavi se sa"
},
"auth.sign-in.title": {
"message": "Prijavi se"
},
"auth.sign-in.use-password": {
"message": "Ili koristite lozinku"
},
"auth.sign-up.action.create-account": {
"message": "Kreirajte nalog"
},
"auth.sign-up.legal-dislaimer": {
"message": "Kreiranjem naloga prihvatate <terms-link>Uslove</terms-link> i <privacy-policy-link>Pravila privatnosti</privacy-policy-link> Modrintha."
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "Lozinke se ne poklapaju!"
},
"auth.sign-up.sign-in-option.title": {
"message": "Već imate nalog?"
},
"auth.sign-up.subscribe.label": {
"message": "Pretplatite se za novosti o Modrinthu"
},
"auth.sign-up.title": {
"message": "Registracija"
},
"auth.sign-up.title.create-account": {
"message": "Ili kreirajte nalog sami"
},
"auth.sign-up.title.sign-up-with": {
"message": "Prijavite se sa"
},
@@ -188,18 +170,6 @@
"auth.verify-email.title": {
"message": "Verifikujte email"
},
"auth.welcome.checkbox.subscribe": {
"message": "Pretplatite se za novosti o Modrinthu"
},
"auth.welcome.label.tos": {
"message": "Kreiranjem naloga pristali ste na <terms-link>Uslove</terms-link> i <privacy-policy-link>Pravila privatnosti</privacy-policy-link> Modrintha."
},
"auth.welcome.long-title": {
"message": "Dobrodošli na Modrinth!"
},
"auth.welcome.title": {
"message": "Dobrodošli"
},
"collection.button.edit-icon": {
"message": "Ikona za izmenu"
},
@@ -665,39 +665,21 @@
"auth.sign-in.2fa.placeholder": {
"message": "Ange kod..."
},
"auth.sign-in.additional-options": {
"message": "<forgot-password-link>Har du glömt lösenordet?</forgot-password-link> • <create-account-link>Skapa ett konto</create-account-link>"
},
"auth.sign-in.sign-in-with": {
"message": "Logga in med"
},
"auth.sign-in.title": {
"message": "Logga in"
},
"auth.sign-in.use-password": {
"message": "Eller använd ett lösenord"
},
"auth.sign-up.action.create-account": {
"message": "Skapa konto"
},
"auth.sign-up.legal-dislaimer": {
"message": "Genom att skapa ett konto godkänner du Modrinths <terms-link>villkor</terms-link> och <privacy-policy-link>sekretesspolicy</privacy-policy-link>."
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "Lösenorden stämmer inte överens!"
},
"auth.sign-up.sign-in-option.title": {
"message": "Har du redan ett konto?"
},
"auth.sign-up.subscribe.label": {
"message": "Jag vill få uppdateringar om Modrinth"
},
"auth.sign-up.title": {
"message": "Registrera dig"
},
"auth.sign-up.title.create-account": {
"message": "Eller skapa ett konto själv"
},
"auth.sign-up.title.sign-up-with": {
"message": "Registrera dig med"
},
@@ -731,21 +713,6 @@
"auth.verify-email.title": {
"message": "Verifiera e-post"
},
"auth.welcome.checkbox.subscribe": {
"message": "Prenumerera för uppdateringar om Modrinth"
},
"auth.welcome.description": {
"message": "Du är nu en del av ett grymt community av skapare och utforskare som redan bygger, laddar ner och håller sig uppdaterade med fantastiska moddar."
},
"auth.welcome.label.tos": {
"message": "När du skapar ett konto godkänner du Modrinths <terms-link>villkor</terms-link> och <privacy-policy-link>sekretesspolicy</privacy-policy-link>."
},
"auth.welcome.long-title": {
"message": "Välkommen till Modrinth!"
},
"auth.welcome.title": {
"message": "Välkommen"
},
"collection.button.edit-icon": {
"message": "Redigera-ikon"
},
@@ -455,36 +455,21 @@
"auth.sign-in.2fa.placeholder": {
"message": "ป้อนรหัส"
},
"auth.sign-in.additional-options": {
"message": "<forgot-password-link> ลืมรหัสผ่าน? </forgot-password-link> • <create-account-link> สร้างบัญชีใหม่ </create-account-link>"
},
"auth.sign-in.sign-in-with": {
"message": "ลงชื่อเข้าใช้ด้วย"
},
"auth.sign-in.title": {
"message": "ลงชื่อเข้าใช้"
},
"auth.sign-up.action.create-account": {
"message": "สร้างบัญชี"
},
"auth.sign-up.legal-dislaimer": {
"message": "ในการสร้างบัญชี คุณยอมรับ <terms-link> ข้อกำหนด </terms-link> และ <privacy-policy-link> นโยบายความเป็นส่วนตัว </privacy-policy-link> ของ Modrinth"
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "รหัสผ่านไม่ตรงกัน!"
},
"auth.sign-up.sign-in-option.title": {
"message": "มีบัญชีอยู่แล้ว?"
},
"auth.sign-up.subscribe.label": {
"message": "สมัครรับข้อมูลการอัพเดทเกี่ยวกับ Modrinth"
},
"auth.sign-up.title": {
"message": "ลงทะเบียน"
},
"auth.sign-up.title.create-account": {
"message": "หรือจะสร้างบัญชีด้วยตัวคุณเอง"
},
"auth.sign-up.title.sign-up-with": {
"message": "สมัครใช้งานด้วย"
},
@@ -518,18 +503,6 @@
"auth.verify-email.title": {
"message": "ยืนยันอีเมล"
},
"auth.welcome.checkbox.subscribe": {
"message": "สมัครรับข้อมูลอัปเดตเกี่ยวกับ Modrinth"
},
"auth.welcome.description": {
"message": "ตอนนี้คุณได้เป็นส่วนหนึ่งของคอมมูนิตี้สุดเจ๋งของเหล่าครีเอเตอร์และนักสำรวจ ที่กำลังสร้างสรรค์ ดาวน์โหลด และอัปเดตม็อดสุดยอดอยู่เสมอ"
},
"auth.welcome.long-title": {
"message": "ยินดีต้อนรับเข้าสู่ modrinth!"
},
"auth.welcome.title": {
"message": "ยินดีต้อนรับ"
},
"collection.button.edit-icon": {
"message": "แก้ไขไอคอน"
},
@@ -737,39 +737,21 @@
"auth.sign-in.2fa.placeholder": {
"message": "Kodu girin..."
},
"auth.sign-in.additional-options": {
"message": "<forgot-password-link>Parolanızı mı unuttunuz?</forgot-password-link> • <create-account-link>Yeni hesap oluştur</create-account-link>"
},
"auth.sign-in.sign-in-with": {
"message": "Şununla giriş yap"
},
"auth.sign-in.title": {
"message": "Giriş Yap"
},
"auth.sign-in.use-password": {
"message": "Veya parola kullanın"
},
"auth.sign-up.action.create-account": {
"message": "Hesap oluştur"
},
"auth.sign-up.legal-dislaimer": {
"message": "Bir hesap oluşturarak, Modrinth'in <terms-link>Kurallar</terms-link> ve <privacy-policy-link>Gizlilik İlkeleri </privacy-policy-link>'ni kabul etmiş bulunursunuz."
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "Parolalar uyuşmuyor!"
},
"auth.sign-up.sign-in-option.title": {
"message": "Zaten bir hesabınız var mı?"
},
"auth.sign-up.subscribe.label": {
"message": "Modrinth hakkında güncellemelere abone ol"
},
"auth.sign-up.title": {
"message": "Kaydol"
},
"auth.sign-up.title.create-account": {
"message": "Veya kendiniz hesap oluşturun"
},
"auth.sign-up.title.sign-up-with": {
"message": "Şunlarla giriş yapın"
},
@@ -803,21 +785,6 @@
"auth.verify-email.title": {
"message": "E-postayı Doğrula"
},
"auth.welcome.checkbox.subscribe": {
"message": "Modrinth'le ilgili güncellemelere abone ol"
},
"auth.welcome.description": {
"message": "Artık inanılmaz modlar yapan, indiren ve geride kalmayan yaratıcıların ve kaşiflerin havalı topluluğunun bir parçasısın."
},
"auth.welcome.label.tos": {
"message": "Bir hesap oluşturarak, Modrinth'in <terms-link>Şartlar</terms-link>'ını ve <privacy-policy-link>Gizlilik İlkeleri</privacy-policy-link>'ni kabul etmiş sayılırsınız."
},
"auth.welcome.long-title": {
"message": "Modrinth'e hoş geldiniz!"
},
"auth.welcome.title": {
"message": "Hoş geldiniz"
},
"collection.button.edit-icon": {
"message": "Simgeyi düzenle"
},
@@ -569,39 +569,21 @@
"auth.sign-in.2fa.placeholder": {
"message": "Уведіть код…"
},
"auth.sign-in.additional-options": {
"message": "<forgot-password-link>Забули пароль?</forgot-password-link> • <create-account-link>Створити обліковий запис</create-account-link>"
},
"auth.sign-in.sign-in-with": {
"message": "Увійдіть за допомогою"
},
"auth.sign-in.title": {
"message": "Вхід"
},
"auth.sign-in.use-password": {
"message": "Або ж з паролем"
},
"auth.sign-up.action.create-account": {
"message": "Створити обліковий запис"
},
"auth.sign-up.legal-dislaimer": {
"message": "Створивши обліковий запис, ви погоджуєтеся з <terms-link>Умовами використання</terms-link> та <privacy-policy-link>Політикою конфіденційности</privacy-policy-link> Modrinth."
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "Паролі не збігаються!"
},
"auth.sign-up.sign-in-option.title": {
"message": "Уже маєте обліковий запис?"
},
"auth.sign-up.subscribe.label": {
"message": "Підписатися на оновлення Modrinth"
},
"auth.sign-up.title": {
"message": "Реєстрація"
},
"auth.sign-up.title.create-account": {
"message": "Або зробіть обліковий запис самотужки"
},
"auth.sign-up.title.sign-up-with": {
"message": "Реєстрація за допомогою"
},
@@ -635,21 +617,6 @@
"auth.verify-email.title": {
"message": "Підтвердити електронну пошту"
},
"auth.welcome.checkbox.subscribe": {
"message": "Підписатися на оновлення Modrinth"
},
"auth.welcome.description": {
"message": "Ви тепер частина чудової спільноти авторів і дослідників, що вже створюють, завантажують та оновлюють дивовижні моди."
},
"auth.welcome.label.tos": {
"message": "Створивши обліковий запис, ви погодилися з <terms-link>Умовами використання</terms-link> та <privacy-policy-link>Політикою конфіденційности</privacy-policy-link> Modrinth."
},
"auth.welcome.long-title": {
"message": "Вітаємо в Modrinth!"
},
"auth.welcome.title": {
"message": "Вітаємо"
},
"collection.button.edit-icon": {
"message": "Редагувати значок"
},
@@ -737,39 +737,21 @@
"auth.sign-in.2fa.placeholder": {
"message": "Nhập mã..."
},
"auth.sign-in.additional-options": {
"message": "<forgot-password-link>Quên mật khẩu à?</forgot-password-link> • <create-account-link>Tạo tài khoản</create-account-link>"
},
"auth.sign-in.sign-in-with": {
"message": "Đăng nhập bằng"
},
"auth.sign-in.title": {
"message": "Đăng nhập"
},
"auth.sign-in.use-password": {
"message": "Hoặc dùng mật khẩu"
},
"auth.sign-up.action.create-account": {
"message": "Tạo tài khoản"
},
"auth.sign-up.legal-dislaimer": {
"message": "Khi bạn tạo tài khoản, bạn đồng ý với <terms-link>Điều khoản sử dụng</terms-link> và <privacy-policy-link>Chính sách quyền riêng tư</privacy-policy-link> của Modrinth."
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "Mật khẩu không khớp!"
},
"auth.sign-up.sign-in-option.title": {
"message": "Đã có tài khoản rồi à?"
},
"auth.sign-up.subscribe.label": {
"message": "Đăng kí để nhận cập nhật về Modrinth"
},
"auth.sign-up.title": {
"message": "Đăng ký"
},
"auth.sign-up.title.create-account": {
"message": "Hoặc tự tạo một tài khoản"
},
"auth.sign-up.title.sign-up-with": {
"message": "Đăng ký bằng"
},
@@ -803,21 +785,6 @@
"auth.verify-email.title": {
"message": "Xác nhận email"
},
"auth.welcome.checkbox.subscribe": {
"message": "Đăng kí để nhận cập nhật về Modrinth"
},
"auth.welcome.description": {
"message": "Bạn hiện đã là một phần của cộng đồng tuyệt vời gồm những người sáng tạo và khám phá, những người đang xây dựng, tải xuống và luôn cập nhật với các mod tuyệt vời."
},
"auth.welcome.label.tos": {
"message": "Khi bạn tạo tài khoản, bạn đồng ý với <terms-link>Điều khoản sử dụng</terms-link> và <privacy-policy-link>Chính sách quyền riêng tư</privacy-policy-link> của Modrinth."
},
"auth.welcome.long-title": {
"message": "Chào mừng đến với Modrinth!"
},
"auth.welcome.title": {
"message": "Chào mừng"
},
"collection.button.edit-icon": {
"message": "Sửa biểu tượng"
},
@@ -734,39 +734,21 @@
"auth.sign-in.2fa.placeholder": {
"message": "输入验证码…"
},
"auth.sign-in.additional-options": {
"message": "<forgot-password-link>忘记密码?</forgot-password-link> • <create-account-link>创建新账户</create-account-link>"
},
"auth.sign-in.sign-in-with": {
"message": "通过以下方式登录"
},
"auth.sign-in.title": {
"message": "登录"
},
"auth.sign-in.use-password": {
"message": "或使用密码"
},
"auth.sign-up.action.create-account": {
"message": "创建账户"
},
"auth.sign-up.legal-dislaimer": {
"message": "创建账户即表示你同意 Modrinth 的<terms-link>使用条款</terms-link>和<privacy-policy-link>隐私政策</privacy-policy-link>。"
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "密码不一致!"
},
"auth.sign-up.sign-in-option.title": {
"message": "已有账户?"
},
"auth.sign-up.subscribe.label": {
"message": "订阅有关 Modrinth 更新的邮件"
},
"auth.sign-up.title": {
"message": "注册"
},
"auth.sign-up.title.create-account": {
"message": "或者自己创建一个账户"
},
"auth.sign-up.title.sign-up-with": {
"message": "通过以下方式注册"
},
@@ -800,21 +782,6 @@
"auth.verify-email.title": {
"message": "验证电子邮箱"
},
"auth.welcome.checkbox.subscribe": {
"message": "订阅有关 Modrinth 的更新邮件"
},
"auth.welcome.description": {
"message": "你现在是这个超棒社区的一员了——与创作者和社区玩家们一起创作、探索、获取模组并持续追踪最新的精彩模组动态吧。"
},
"auth.welcome.label.tos": {
"message": "创建账户即表示你已同意 Modrinth 的<terms-link>使用条款</terms-link>和<privacy-policy-link>隐私政策</privacy-policy-link>。"
},
"auth.welcome.long-title": {
"message": "欢迎来到 Modrinth"
},
"auth.welcome.title": {
"message": "欢迎"
},
"collection.button.edit-icon": {
"message": "编辑图标"
},
@@ -779,39 +779,21 @@
"auth.sign-in.2fa.placeholder": {
"message": "輸入驗證碼..."
},
"auth.sign-in.additional-options": {
"message": "<forgot-password-link>忘記密碼?</forgot-password-link> • <create-account-link>建立帳號</create-account-link>"
},
"auth.sign-in.sign-in-with": {
"message": "使用以下方式登入"
},
"auth.sign-in.title": {
"message": "登入"
},
"auth.sign-in.use-password": {
"message": "或使用密碼"
},
"auth.sign-up.action.create-account": {
"message": "建立帳號"
},
"auth.sign-up.legal-dislaimer": {
"message": "建立帳號即表示您同意 Modrinth 的《<terms-link>使用條款</terms-link>》及《<privacy-policy-link>隱私權政策</privacy-policy-link>》。"
},
"auth.sign-up.notification.password-mismatch.text": {
"message": "密碼不相符!"
},
"auth.sign-up.sign-in-option.title": {
"message": "已經有帳號了?"
},
"auth.sign-up.subscribe.label": {
"message": "訂閱 Modrinth 的最新消息"
},
"auth.sign-up.title": {
"message": "註冊"
},
"auth.sign-up.title.create-account": {
"message": "或自行建立帳號"
},
"auth.sign-up.title.sign-up-with": {
"message": "使用以下方式註冊"
},
@@ -845,21 +827,6 @@
"auth.verify-email.title": {
"message": "驗證電子郵件"
},
"auth.welcome.checkbox.subscribe": {
"message": "訂閱 Modrinth 的最新消息"
},
"auth.welcome.description": {
"message": "你現在已加入由創作者與探索者組成的精彩社群,大家都在積極打造、下載並持續掌握優質模組的最新動態。"
},
"auth.welcome.label.tos": {
"message": "建立帳號即表示您已同意 Modrinth 的《<terms-link>使用條款</terms-link>》及《<privacy-policy-link>隱私權政策</privacy-policy-link>》。"
},
"auth.welcome.long-title": {
"message": "歡迎使用 Modrinth"
},
"auth.welcome.title": {
"message": "歡迎"
},
"collection.button.edit-icon": {
"message": "編輯圖示"
},
+1 -1
View File
@@ -1125,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'
@@ -348,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()
@@ -502,7 +502,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { onServerPrefetch } 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 { projectQueryOptions, STALE_TIME } from '~/composables/queries/project'
import { versionQueryOptions } from '~/composables/queries/version'
import { createDataPackVersion } from '~/helpers/package.js'
@@ -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-14.5rem)] place-items-center px-2.5 pb-20 pt-10">
<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-[27rem] flex-col gap-6 border border-solid border-surface-5 !p-6"
>
<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>
+98 -35
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-[27rem] flex-col gap-6 border border-solid border-surface-5 !p-6"
>
<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>
<HCaptcha v-if="globals?.captcha_enabled" ref="captcha" v-model="token" />
<div 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 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) || !email"
@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()
}
+131 -197
View File
@@ -1,171 +1,72 @@
<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-passkey-sign-in="beginPasskeySignin"
: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'
import { getPasskeyCredential } from '@/helpers/passkey.ts'
type AuthProvider = 'discord' | 'google' | 'github' | 'gitlab' | 'steam' | 'microsoft' | 'passkey'
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 +74,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 +88,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 +124,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 +145,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 +166,68 @@ 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 beginPasskeySignin() {
startLoading()
try {
const start = await client.labrinth.auth_v2.authenticatePasskeyStart()
const credential = await getPasskeyCredential(start.options.publicKey)
const result = await client.labrinth.auth_v2.authenticatePasskeyFinish({
flow: start.flow,
credential,
})
pendingSignInOAuthProvider.value = 'passkey'
await finishSignIn(result.session)
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: getErrorMessage(err),
type: 'error',
})
}
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/')) {
@@ -316,14 +248,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-[27rem] flex-col gap-6 border border-solid border-surface-5 !p-6"
>
<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()
+152 -131
View File
@@ -9,24 +9,31 @@
: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="input-group push-right">
<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="flex justify-end gap-2.5">
<ButtonStyled>
<button @click="$refs.changeEmailModal.hide()">
<XIcon />
@@ -41,9 +48,10 @@
</ButtonStyled>
</div>
</div>
</Modal>
<Modal
</NewModal>
<NewModal
ref="managePasswordModal"
width="600px"
:header="`${
removePasswordMode
? formatMessage(messages.passwordHeaderRemove)
@@ -52,61 +60,75 @@
: 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">
<div class="flex justify-end gap-2.5">
<ButtonStyled>
<button @click="$refs.managePasswordModal.hide()">
<XIcon />
@@ -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,10 +198,10 @@
: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">
<div class="flex justify-end gap-2.5">
<ButtonStyled>
<button @click="$refs.manageTwoFactorModal.hide()">
<XIcon />
@@ -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,18 +268,18 @@
: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>
</template>
<div class="input-group push-right">
<div class="flex justify-end gap-2.5">
<ButtonStyled v-if="twoFactorStep === 1">
<button @click="twoFactorStep = 0">
<LeftArrowIcon />
@@ -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>
@@ -419,6 +440,7 @@
</ButtonStyled>
</div>
</div>
<PasskeySettings />
</section>
<section id="data-export" class="universal-card">
@@ -471,13 +493,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 +514,8 @@ 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 PasskeySettings from '~/components/ui/auth/PasskeySettings.vue'
import { getAuthUrl, removeAuthProvider } from '~/composables/auth.ts'
definePageMeta({
middleware: 'auth',
@@ -957,6 +982,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 +1047,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>
+1 -1
View File
@@ -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'
@@ -0,0 +1,8 @@
import { promotePendingSignInOAuthProvider } from '@/composables/auth.ts'
export default defineNuxtPlugin(async () => {
const auth = await useAuth()
if (auth.value.user) {
promotePendingSignInOAuthProvider()
}
})
+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
View File
@@ -173,3 +173,6 @@ MURALPAY_SOURCE_ACCOUNT_ID=00000000-0000-0000-0000-000000000000
DEFAULT_AFFILIATE_REVENUE_SPLIT=0.1
SERVER_PING_TIMEOUT=10000
SERVER_PING_RETRIES=3
# Display name for Webauthn Authenticators
WEBAUTHN_RP_NAME=Modrinth
+3
View File
@@ -195,3 +195,6 @@ DEFAULT_AFFILIATE_REVENUE_SPLIT=0.1
SERVER_PING_TIMEOUT=10000
SERVER_PING_RETRIES=3
SERVER_PING_MIN_INTERVAL_SEC=1800
# Display name for Webauthn Authenticators
WEBAUTHN_RP_NAME=Modrinth
+10
View File
@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Ask2AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="EMPTY_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/labrinth.iml" filepath="$PROJECT_DIR$/.idea/labrinth.iml" />
</modules>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/../.." vcs="Git" />
</component>
</project>
@@ -0,0 +1,58 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, user_id, name, credential_id,\n passkey AS \"passkey: sqlx::types::Json<Passkey>\",\n last_used, created_at\n FROM user_passkeys\n WHERE credential_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "user_id",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "credential_id",
"type_info": "Bytea"
},
{
"ordinal": 4,
"name": "passkey: sqlx::types::Json<Passkey>",
"type_info": "Jsonb"
},
{
"ordinal": 5,
"name": "last_used",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Bytea"
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
false
]
},
"hash": "05d26562a95715d65bbb2fd1c4163ebb067931f4c3caeb93601c98f1d533983b"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM payouts_values_notifications WHERE notified = FALSE AND user_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null
]
},
"hash": "0f3d943e4fc48a94363b77c8a7d36eb1dd626e77331d8278c406df952691be4c"
}
@@ -1,26 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n id,\n status AS \"status: PayoutStatus\"\n FROM payouts\n ORDER BY id\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "status: PayoutStatus",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false
]
},
"hash": "1adbd24d815107e13bc1440c7a8f4eeff66ab4165a9f4980032e114db4dc1286"
}
@@ -1,20 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM payouts_values_notifications WHERE notified = FALSE",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "20cff8fdf7971e91c9d473b9a4663ce02ca16781e32232ae0fa7a0af1973d3a4"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE user_passkeys SET name = $1\n WHERE id = $2 AND user_id = $3\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int8",
"Int8"
]
},
"nullable": []
},
"hash": "29fc17743ca5cf06fcea50ebc477576b2fd7fe535afee2f3136d6b153bbf4129"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM sessions WHERE user_id = $1 RETURNING id, session\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "session",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
false
]
},
"hash": "31c1a2872410a5834e9995ad73cdc1cdeda4f3386e385dd66bec5be2aaa75b5b"
}
@@ -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,15 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM user_passkeys\n WHERE id = $1 AND user_id = $2\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": []
},
"hash": "42a9ddd851497b7a340e00b8289aa56692577571ec6d21131b4a31f55d37b98e"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE user_passkeys\n SET passkey = $1, last_used = NOW()\n WHERE id = $2\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb",
"Int8"
]
},
"nullable": []
},
"hash": "538bfc1694ce1d177ea20f269353922e836d2dfd9eb447e278f38266d13c8e73"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO payouts_values_notifications (date_available, user_id, notified)\n VALUES ($1, $2, FALSE)\n ON CONFLICT (date_available, user_id) DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Timestamptz",
"Int8"
]
},
"nullable": []
},
"hash": "6678cd4c51771cfaae2be8021ba66908ea41a06ba858dc5b523aef6aae27b850"
}
@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO payouts_values (user_id, mod_id, amount, created, date_available)\n VALUES ($1, NULL, $2, NOW(), $3)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Numeric",
"Timestamptz"
]
},
"nullable": []
},
"hash": "69a1cb4b7f1115a990d1fc4805d58541fc78e910111c09ba3d50a12d9ca4a9f8"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO user_passkeys (\n id, user_id, name, credential_id, passkey, created_at, last_used\n )\n VALUES (\n $1, $2 ,$3, $4, $5, $6, $7\n )\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Int8",
"Varchar",
"Bytea",
"Jsonb",
"Timestamptz",
"Timestamptz"
]
},
"nullable": []
},
"hash": "7de293b153f075b8e44bf8ec8eee0fef0bc06a8aafd844c38aff3dcbf63e1bc9"
}
@@ -0,0 +1,58 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, user_id, name, credential_id,\n passkey AS \"passkey: sqlx::types::Json<Passkey>\",\n last_used, created_at\n FROM user_passkeys\n WHERE user_id = $1\n ORDER BY created_at DESC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "user_id",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "credential_id",
"type_info": "Bytea"
},
{
"ordinal": 4,
"name": "passkey: sqlx::types::Json<Passkey>",
"type_info": "Jsonb"
},
{
"ordinal": 5,
"name": "last_used",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
false
]
},
"hash": "806c5ed76a076bfd060fca40aa4cff8823503afefd68c2a2a7a6b879dfdaea2d"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM user_passkeys WHERE id=$1)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null
]
},
"hash": "9851b2891716958cb2e0eb8f2deccd25d6f36dfc03b69e83079bfce0bf2030fb"
}
@@ -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"
}
@@ -1,20 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT status AS \"status: PayoutStatus\" FROM payouts WHERE id = 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "status: PayoutStatus",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "b92b5bb7d179c4fcdbc45600ccfd2402f52fea71e27b08e7926fcc2a9e62c0f3"
}
@@ -1,18 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO payouts (id, method, platform_id, status, user_id, amount, created)\n VALUES ($1, $2, $3, $4, $5, 10.0, NOW())\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Text",
"Text",
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "cd5ccd618fb3cc41646a6de86f9afedb074492b4ec7f2457c14113f5fd13aa02"
}
@@ -1,17 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO payouts (id, method, platform_id, status, user_id, amount, created)\n VALUES ($1, $2, NULL, $3, $4, 10.00, NOW())\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Text",
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "cec4240c7c848988b3dfd13e3f8e5c93783c7641b019fdb698a1ec0be1393606"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM user_passkeys\n WHERE id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "ddef9fee29f75736494b196a10dfe363a86e42417a047a6ed80f2c62811d5c2d"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND body->>'type' = 'payout_available'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null
]
},
"hash": "fd5c773a61d35bcd71503ec4d5f86e8917cfab9679d5064074681663ba467e41"
}
+1
View File
@@ -25,6 +25,7 @@
- `Modrinth-Admin: feedbeef` as admin key
- If some steps require you to create a project/mod or version for testing, ask the user to go into the web frontend and manually create a project/version
- When using `sqlx::query` etc. always use the macro form like `sqlx::query!` or `sqlx::query_scalar!` - never the plain function form. Avoid using `query_as!`.
- Do not use `()` as an error type for operations, unless you have a very good reason. Either make a new error type, or use `eyre::Report`.
- Do not run `cargo test`, even for a single specific test, unless explicitly prompted to by the user, since it takes a long time to run.
- You can force a search reindex by:
- Running `cd apps/labrinth && cargo run -p labrinth -- --run-background-task index-search` (prefer this if backend is running locally)
+5
View File
@@ -131,6 +131,11 @@ utoipa-actix-web = { workspace = true }
utoipa-scalar = { workspace = true, features = ["actix-web"] }
uuid = { workspace = true, features = ["fast-rng", "serde", "v4", "v7"] }
validator = { workspace = true, features = ["derive"] }
webauthn-rs = { workspace = true, features = [
"conditional-ui",
"danger-allow-state-serialisation"
] }
webauthn-rs-proto = { workspace = true }
webp = { workspace = true }
woothee = { workspace = true }
yaserde = { workspace = true, features = ["derive"] }
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN eligibility_verified_at TIMESTAMPTZ;
@@ -0,0 +1,11 @@
CREATE TABLE user_passkeys (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
credential_id BYTEA NOT NULL UNIQUE,
passkey JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used TIMESTAMPTZ
);
CREATE INDEX user_passkeys_user_id ON user_passkeys (user_id);

Some files were not shown because too many files have changed in this diff Show More