feat: add support for multiple account types in database

This commit is contained in:
2025-07-16 20:33:58 +03:00
parent 3f606a08aa
commit 5a10292add
11 changed files with 274 additions and 105 deletions

View File

@@ -1,17 +1,9 @@
<template>
<div
v-if="mode !== 'isolated'"
ref="button"
<div v-if="mode !== 'isolated'" ref="button"
class="button-base mt-2 px-3 py-2 bg-button-bg rounded-xl flex items-center gap-2"
:class="{ expanded: mode === 'expanded' }"
@click="toggleMenu"
>
<Avatar
size="36px"
:src="
selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png'
"
/>
:class="{ expanded: mode === 'expanded' }" @click="toggleMenu">
<Avatar size="36px" :src="selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png'
" />
<div class="flex flex-col w-full">
<span>
<component :is="getAccountType(selectedAccount)" v-if="selectedAccount" class="vector-icon" />
@@ -32,30 +24,23 @@
</h4>
<p>Selected</p>
</div>
<Button
v-tooltip="'Log out'"
icon-only
color="raised"
@click="logout(selectedAccount.profile.id)"
>
<Button v-tooltip="'Log out'" icon-only color="raised" @click="logout(selectedAccount.profile.id)">
<TrashIcon />
</Button>
</div>
<div v-else class="login-section account">
<h4>Not signed in</h4>
<Button
v-tooltip="'Log in'"
:disabled="loginDisabled"
icon-only
color="primary"
@click="login()"
>
<MicrosoftIcon v-if="!loginDisabled"/>
<Button v-tooltip="'Log via Microsoft'" :disabled="microsoftLoginDisabled" icon-only @click="login()">
<MicrosoftIcon v-if="!microsoftLoginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
</Button>
<Button v-tooltip="'Add offline'" icon-only @click="tryOfflineLogin()">
<Button v-tooltip="'Add offline account'" icon-only @click="showOfflineLoginModal()">
<PirateIcon />
</Button>
<Button v-tooltip="'Log via Ely.by'" icon-only @click="loginViaElyBy()">
<ElyByIcon v-if="!elybyLoginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
</Button>
</div>
<div v-if="displayAccounts.length > 0" class="account-group">
<div v-for="account in displayAccounts" :key="account.profile.id" class="account-row">
@@ -72,50 +57,85 @@
</div>
</div>
<div v-if="accounts.length > 0" class="login-section account centered">
<Button v-tooltip="'Log in'" icon-only @click="login()">
<MicrosoftIcon />
<Button v-tooltip="'Log via Microsoft'" icon-only @click="login()">
<MicrosoftIcon v-if="!microsoftLoginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
</Button>
<Button v-tooltip="'Add offline'" icon-only @click="tryOfflineLogin()">
<Button v-tooltip="'Add offline account'" icon-only @click="showOfflineLoginModal()">
<PirateIcon />
</Button>
<Button v-tooltip="'Log via Ely.by'" icon-only @click="loginViaElyBy()">
<ElyByIcon v-if="!elybyLoginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
</Button>
</div>
</Card>
</transition>
<ModalWrapper ref="loginOfflineModal" class="modal" header="Add new offline account">
<div class="modal-body">
<div class="label">Enter offline username</div>
<input type="text" v-model="playerName" placeholder="Provide offline player name" />
<Button icon-only color="secondary" @click="offlineLoginFinally()">
Continue
</Button>
<ModalWrapper ref="addOfflineModal" class="modal" header="Add new offline account">
<div class="flex flex-col gap-4 px-6 py-5">
<label class="label">Enter your player name</label>
<input
type="text"
v-model="offlinePlayerName"
placeholder="Your player name here..."
class="input"
/>
<div class="mt-6 ml-auto">
<Button
icon-only
color="primary"
@click="addOfflineProfile()"
class="continue-button"
>
Login
</Button>
</div>
</div>
</ModalWrapper>
<ModalWrapper ref="loginErrorModal" class="modal" header="Error while proceed">
<div class="modal-body">
<div class="label">Error occurred while adding offline account</div>
<Button color="primary" @click="retryOfflineLogin()">
Try again
</Button>
<ModalWrapper ref="inputErrorModal" class="modal" header="Error while proceeding">
<div class="flex flex-col gap-4 px-6 py-5">
<label class="text-base font-medium text-red-700">
An error occurred while adding the offline account. Please follow the instructions below.
</label>
<ul class="list-disc list-inside text-sm space-y-1">
<li>Check that you have entered the correct player name.</li>
<li>
Player name must be at least {{ minOfflinePlayerNameLength }} characters long and no more than
{{ maxOfflinePlayerNameLength }} characters.
</li>
</ul>
<div class="mt-6 ml-auto">
<Button
color="primary"
@click="retryAddOfflineProfile"
class="retry-button"
>
Try again
</Button>
</div>
</div>
</ModalWrapper>
<ModalWrapper ref="unexpectedErrorModal" class="modal" header="Ошибка">
<ModalWrapper ref="exceptionErrorModal" class="modal" header="Unexpected error occurred">
<div class="modal-body">
<div class="label">Unexcepted error</div>
<label class="label">An unexpected error has occurred. Please try again later.</label>
</div>
</ModalWrapper>
</template>
<script setup>
import {
import {
DropdownIcon,
PlusIcon,
TrashIcon,
LogInIcon,
PirateIcon as Offline,
MicrosoftIcon as License,
ElyByIcon as ElyBy,
MicrosoftIcon,
PirateIcon,
SpinnerIcon } from '@modrinth/assets'
ElyByIcon,
SpinnerIcon
} from '@modrinth/assets'
import { Avatar, Button, Card } from '@modrinth/ui'
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
import {
@@ -145,48 +165,80 @@ defineProps({
const emit = defineEmits(['change'])
const accounts = ref({})
const loginDisabled = ref(false)
const microsoftLoginDisabled = ref(false)
const elybyLoginDisabled = ref(false)
const defaultUser = ref()
const loginOfflineModal = ref(null)
const loginErrorModal = ref(null)
const unexpectedErrorModal = ref(null)
const playerName = ref('')
async function tryOfflineLogin() { // [AR] Feature
loginOfflineModal.value.show()
// [AR] Feature
const addOfflineModal = ref(null)
const inputErrorModal = ref(null)
const exceptionErrorModal = ref(null)
const offlinePlayerName = ref('')
const minOfflinePlayerNameLength = 2
const maxOfflinePlayerNameLength = 20
// [AR] • Feature
function getAccountType(account) {
switch (account.account_type) {
case 'microsoft':
return License
case 'pirate':
return Offline
case 'elyby':
return ElyBy
}
}
async function offlineLoginFinally() { // [AR] Feature
const name = playerName.value
if (name.length > 1 && name.length < 20 && name !== '') {
const loggedIn = await offline_login(name).catch(handleError)
loginOfflineModal.value.hide()
if (loggedIn) {
await setAccount(loggedIn)
// [AR] Feature
function showOfflineLoginModal() {
addOfflineModal.value?.show()
}
// [AR] • Feature
function retryAddOfflineProfile() {
inputErrorModal.value?.hide()
showOfflineLoginModal()
}
// [AR] • Feature
async function addOfflineProfile() {
const name = offlinePlayerName.value.trim()
const isValidName = name.length >= minOfflinePlayerNameLength && name.length <= maxOfflinePlayerNameLength
if (!isValidName) {
addOfflineModal.value?.hide()
inputErrorModal.value?.show()
offlinePlayerName.value = ''
return
}
try {
const result = await offline_login(name)
addOfflineModal.value?.hide()
if (result) {
await setAccount(result)
await refreshValues()
} else {
unexpectedErrorModal.value.show()
exceptionErrorModal.value?.show()
}
playerName.value = ''
} else {
playerName.value = ''
loginOfflineModal.value.hide()
loginErrorModal.value.show()
} catch (error) {
handleError(error)
exceptionErrorModal.value?.show()
} finally {
offlinePlayerName.value = ''
}
}
function retryOfflineLogin() { // [AR] Feature
loginErrorModal.value.hide()
tryOfflineLogin()
// [AR] Feature
// TODO:
async function loginViaElyBy() {
elybyLoginDisabled.value = true
console.log("Login via Ely.by clicked!")
elybyLoginDisabled.value = false
}
function getAccountType(account) { // [AR] Feature
if (account.access_token != "null" && account.access_token != null && account.access_token != "") {
return License
} else {
return Offline
}
}
const equippedSkin = ref(null)
const headUrlCache = ref(new Map())
@@ -212,13 +264,13 @@ async function refreshValues() {
}
function setLoginDisabled(value) {
loginDisabled.value = value
microsoftLoginDisabled.value = value
}
defineExpose({
refreshValues,
setLoginDisabled,
loginDisabled,
loginDisabled: microsoftLoginDisabled,
})
await refreshValues()
@@ -264,7 +316,7 @@ async function setAccount(account) {
}
async function login() {
loginDisabled.value = true
microsoftLoginDisabled.value = true
const loggedIn = await login_flow().catch(handleSevereError)
if (loggedIn) {
@@ -273,7 +325,7 @@ async function login() {
}
trackEvent('AccountLogIn')
loginDisabled.value = false
microsoftLoginDisabled.value = false
}
const logout = async (id) => {

View File

@@ -41,7 +41,7 @@
]
},
"productName": "AstralRinth App",
"version": "0.10.304",
"version": "0.10.305",
"mainBinaryName": "AstralRinth App",
"identifier": "AstralRinthApp",
"plugins": {

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n uuid, active, username, access_token, refresh_token, expires\n FROM minecraft_users\n WHERE active = TRUE\n ",
"query": "\n SELECT\n uuid, active, username, access_token, refresh_token, expires, account_type\n FROM minecraft_users\n WHERE active = TRUE\n ",
"describe": {
"columns": [
{
@@ -32,6 +32,11 @@
"name": "expires",
"ordinal": 5,
"type_info": "Integer"
},
{
"name": "account_type",
"ordinal": 6,
"type_info": "Text"
}
],
"parameters": {
@@ -43,8 +48,9 @@
false,
false,
false,
false,
false
]
},
"hash": "bf7d47350092d87c478009adaab131168e87bb37aa65c2156ad2cb6198426d8c"
"hash": "57214178fb3a0ccd8f67457e9732a706cbc4a4f5190c9320d1ad6111b9711d63"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n uuid, active, username, access_token, refresh_token, expires\n FROM minecraft_users\n ",
"query": "\n SELECT\n uuid, active, username, access_token, refresh_token, expires, account_type\n FROM minecraft_users\n ",
"describe": {
"columns": [
{
@@ -32,6 +32,11 @@
"name": "expires",
"ordinal": 5,
"type_info": "Integer"
},
{
"name": "account_type",
"ordinal": 6,
"type_info": "Text"
}
],
"parameters": {
@@ -43,8 +48,9 @@
false,
false,
false,
false,
false
]
},
"hash": "727e3e1bc8625bbcb833920059bb8cea926ac6c65d613904eff1d740df30acda"
"hash": "5c803f3d90c147210e8e7a7a6d7234d3801bc38c23e1e02fbd8fa08ae51e8f08"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO minecraft_users (uuid, active, username, access_token, refresh_token, expires, account_type)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ON CONFLICT (uuid) DO UPDATE SET\n active = $2,\n username = $3,\n access_token = $4,\n refresh_token = $5,\n expires = $6,\n account_type = $7\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "8f7d4406ddae4a158eabb20fc6a8ffb21c4e22c7ff33459df5049ee441fa0467"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO minecraft_users (uuid, active, username, access_token, refresh_token, expires)\n VALUES ($1, $2, $3, $4, $5, $6)\n ON CONFLICT (uuid) DO UPDATE SET\n active = $2,\n username = $3,\n access_token = $4,\n refresh_token = $5,\n expires = $6\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "d719cf2f6f87c5ea7ea6ace2d6a1828ee58a724f06a91633b8a40b4e04d0b9a0"
}

View File

@@ -0,0 +1,5 @@
-- [AR] - SQL Migration
ALTER TABLE minecraft_users ADD COLUMN account_type varchar(32) NOT NULL DEFAULT 'unknown';
UPDATE minecraft_users SET account_type = 'microsoft' WHERE access_token != 'null';
UPDATE minecraft_users SET account_type = 'pirate' WHERE access_token == 'null';

View File

@@ -131,6 +131,7 @@ where
expires: legacy_credentials.expires,
active: minecraft_auth.default_user == Some(uuid)
|| minecraft_users_len == 1,
account_type: legacy_credentials.account_type,
}
.upsert(exec)
.await?;
@@ -518,6 +519,7 @@ struct LegacyCredentials {
pub access_token: String,
pub refresh_token: String,
pub expires: DateTime<Utc>,
pub account_type: String,
}
#[derive(Deserialize, Debug)]

View File

@@ -191,6 +191,7 @@ pub async fn login_finish(
expires: oauth_token.date
+ Duration::seconds(oauth_token.value.expires_in as i64),
active: true,
account_type: AccountType::Microsoft.as_lowercase_str(),
};
// During login, we need to fetch the online profile at least once to get the
@@ -229,6 +230,7 @@ pub async fn offline_auth(
refresh_token: refresh_token,
expires: Utc::now() + Duration::days(365 * 99),
active: true,
account_type: AccountType::Pirate.as_lowercase_str(),
};
credentials.offline_profile = MinecraftProfile {
@@ -242,6 +244,30 @@ pub async fn offline_auth(
Ok(credentials)
}
/// [AR] • Feature
#[derive(Deserialize, Debug)]
pub enum AccountType {
Unknown,
Microsoft,
Pirate,
ElyBy,
}
impl AccountType {
fn as_str(&self) -> &'static str {
match self {
AccountType::Unknown => "Unknown",
AccountType::Microsoft => "Microsoft",
AccountType::Pirate => "Pirate",
AccountType::ElyBy => "ElyBy",
}
}
fn as_lowercase_str(&self) -> String {
self.as_str().to_lowercase()
}
}
#[derive(Deserialize, Debug)]
pub struct Credentials {
/// The offline profile of the user these credentials are for.
@@ -255,6 +281,7 @@ pub struct Credentials {
pub refresh_token: String,
pub expires: DateTime<Utc>,
pub active: bool,
pub account_type: String,
}
/// An entry in the player profile cache, keyed by player UUID.
@@ -480,7 +507,7 @@ impl Credentials {
let res = sqlx::query!(
"
SELECT
uuid, active, username, access_token, refresh_token, expires
uuid, active, username, access_token, refresh_token, expires, account_type
FROM minecraft_users
WHERE active = TRUE
"
@@ -503,6 +530,7 @@ impl Credentials {
.single()
.unwrap_or_else(Utc::now),
active: x.active == 1,
account_type: x.account_type,
};
credentials.refresh(exec).await.ok();
Some(credentials)
@@ -517,7 +545,7 @@ impl Credentials {
let res = sqlx::query!(
"
SELECT
uuid, active, username, access_token, refresh_token, expires
uuid, active, username, access_token, refresh_token, expires, account_type
FROM minecraft_users
"
)
@@ -537,6 +565,7 @@ impl Credentials {
.single()
.unwrap_or_else(Utc::now),
active: x.active == 1,
account_type: x.account_type,
};
async move {
@@ -572,14 +601,15 @@ impl Credentials {
sqlx::query!(
"
INSERT INTO minecraft_users (uuid, active, username, access_token, refresh_token, expires)
VALUES ($1, $2, $3, $4, $5, $6)
INSERT INTO minecraft_users (uuid, active, username, access_token, refresh_token, expires, account_type)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (uuid) DO UPDATE SET
active = $2,
username = $3,
access_token = $4,
refresh_token = $5,
expires = $6
expires = $6,
account_type = $7
",
uuid,
self.active,
@@ -587,6 +617,7 @@ impl Credentials {
self.access_token,
self.refresh_token,
expires,
self.account_type,
)
.execute(exec)
.await?;
@@ -649,6 +680,7 @@ impl Serialize for Credentials {
ser.serialize_field("refresh_token", &self.refresh_token)?;
ser.serialize_field("expires", &self.expires)?;
ser.serialize_field("active", &self.active)?;
ser.serialize_field("account_type", &self.account_type)?;
ser.end()
}
}

View File

@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="10mm"
height="10mm"
viewBox="0 0 10 10"
version="1.1"
id="svg1"
inkscape:version="1.4.2 (ebf0e940, 2025-05-08)"
sodipodi:docname="elyby-icon.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:document-units="mm"
inkscape:zoom="20.48"
inkscape:cx="13.891602"
inkscape:cy="18.09082"
inkscape:window-width="1776"
inkscape:window-height="1236"
inkscape:window-x="244"
inkscape:window-y="25"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<rect
style="fill:#00c600;stroke-width:0.264999;paint-order:markers fill stroke;fill-opacity:1"
id="rect1"
width="10"
height="10"
x="0"
y="0"
rx="1"
ry="1" />
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:9.61119px;font-family:Arial;-inkscape-font-specification:'Arial, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;text-align:start;writing-mode:lr-tb;direction:ltr;text-anchor:start;fill:#ffffff;fill-opacity:1;stroke-width:0.80219;paint-order:markers fill stroke"
x="1.0449646"
y="9.1731787"
id="text1"
inkscape:label="text1"
transform="scale(1.1466469,0.87210806)"><tspan
sodipodi:role="line"
id="tspan1"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:9.61119px;font-family:Arial;-inkscape-font-specification:'Arial, Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ffffff;fill-opacity:1;stroke-width:0.802193"
x="1.0449646"
y="9.1731787">E</tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -89,6 +89,7 @@ import _PirateIcon from './icons/pirate.svg?component'
import _MicrosoftIcon from './icons/microsoft.svg?component'
import _PirateShipIcon from './icons/pirate-ship.svg?component'
import _AstralRinthLogo from './icons/astralrinth-logo.svg?component'
import _ElyByIcon from './icons/elyby-icon.svg?component'
// [AR] Feature. Exports
@@ -96,6 +97,7 @@ export const PirateIcon = _PirateIcon
export const MicrosoftIcon = _MicrosoftIcon
export const PirateShipIcon = _PirateShipIcon
export const AstralRinthLogo = _AstralRinthLogo
export const ElyByIcon = _ElyByIcon
// Skin Models
export { default as ClassicPlayerModel } from './models/classic-player.gltf?url'