[feat] persist user ui customization (#3743)
This commit is contained in:
@@ -204,6 +204,7 @@ async def get_user_from_account(
|
||||
super_user=account.is_super_user,
|
||||
fiat_providers=account.fiat_providers,
|
||||
has_password=account.password_hash is not None,
|
||||
ui_customization=account.ui_customization or {},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -848,3 +848,11 @@ async def m042_index_accounts(db: Connection):
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_{index} ON accounts ("{index}");
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m043_add_ui_customization_to_accounts(db: Connection):
|
||||
"""
|
||||
Adds ui_customization column to accounts.
|
||||
Used for server side persistence of UI customization settings.
|
||||
"""
|
||||
await db.execute("ALTER TABLE accounts ADD COLUMN ui_customization TEXT")
|
||||
|
||||
@@ -187,6 +187,7 @@ class Account(AccountId):
|
||||
pubkey: str | None = None
|
||||
email: str | None = None
|
||||
extra: UserExtra = UserExtra()
|
||||
ui_customization: dict = Field(default_factory=dict)
|
||||
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
@@ -288,6 +289,7 @@ class User(BaseModel):
|
||||
fiat_providers: list[str] = []
|
||||
has_password: bool = False
|
||||
extra: UserExtra = UserExtra()
|
||||
ui_customization: dict = Field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def wallet_ids(self) -> list[str]:
|
||||
|
||||
@@ -467,7 +467,7 @@ async def reset_password(data: ResetUserPassword) -> JSONResponse:
|
||||
return _auth_success_response(account.username, user_id, account.email)
|
||||
|
||||
|
||||
@auth_router.put("/update")
|
||||
@auth_router.patch("")
|
||||
async def update(
|
||||
data: UpdateUser, account: Account = Depends(check_account_exists)
|
||||
) -> User | None:
|
||||
@@ -483,6 +483,22 @@ async def update(
|
||||
return await get_user_from_account(account)
|
||||
|
||||
|
||||
@auth_router.patch("/ui")
|
||||
async def update_ui_customization(
|
||||
req: Request, account: Account = Depends(check_account_exists)
|
||||
) -> Account:
|
||||
ui_customization = await req.json()
|
||||
account.ui_customization = {**(account.ui_customization or {}), **ui_customization}
|
||||
|
||||
if len(account.ui_customization or {}) > 1000 * 1024:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST, "UI customization too large. Drop some fields."
|
||||
)
|
||||
|
||||
await update_user_account(account)
|
||||
return account
|
||||
|
||||
|
||||
@auth_router.put("/first_install")
|
||||
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
|
||||
if not settings.first_install:
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -154,6 +154,9 @@ window._lnbitsApi = {
|
||||
name: name
|
||||
})
|
||||
},
|
||||
updateUiCustomization(data = {}) {
|
||||
return this.request('patch', '/api/v1/auth/ui', null, data)
|
||||
},
|
||||
resetWalletKeys(wallet) {
|
||||
return this.request('put', `/api/v1/wallet/reset/${wallet.id}`).then(
|
||||
res => {
|
||||
|
||||
@@ -14,7 +14,8 @@ window.LNbits = {
|
||||
fiat_providers: data.fiat_providers || [],
|
||||
super_user: data.super_user,
|
||||
extra: data.extra ?? {},
|
||||
hasPassword: data.has_password ?? false
|
||||
hasPassword: data.has_password ?? false,
|
||||
uiCustomization: data.ui_customization || {}
|
||||
}
|
||||
const mapWallet = this.wallet
|
||||
obj.wallets = obj.wallets.map(mapWallet).sort((a, b) => {
|
||||
|
||||
@@ -69,6 +69,17 @@ window.app.component('lnbits-header', {
|
||||
window.location = '/users'
|
||||
} catch (e) {
|
||||
console.warn(e)
|
||||
}
|
||||
},
|
||||
async handleLanguageChanged(lang) {
|
||||
try {
|
||||
await LNbits.api.updateUiCustomization({locale: lang.locale})
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'Language Updated',
|
||||
caption: lang.locale
|
||||
})
|
||||
} catch (e) {
|
||||
LNbits.utils.notifyApiError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
window.app.component('lnbits-language-dropdown', {
|
||||
template: '#lnbits-language-dropdown',
|
||||
computed: {
|
||||
currentLanguage() {
|
||||
return (
|
||||
this.langs.find(lang => lang.value === window.i18n.global.locale) || {
|
||||
value: 'en',
|
||||
label: 'English',
|
||||
display: '🇬🇧 EN'
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
activeLanguage(lang) {
|
||||
return window.i18n.global.locale === lang
|
||||
@@ -8,6 +19,7 @@ window.app.component('lnbits-language-dropdown', {
|
||||
this.g.locale = newValue
|
||||
window.i18n.global.locale = newValue
|
||||
this.$q.localStorage.set('lnbits.lang', newValue)
|
||||
this.$emit('language-changed', newValue)
|
||||
}
|
||||
},
|
||||
data() {
|
||||
|
||||
@@ -9,6 +9,10 @@ window.app.component('lnbits-theme', {
|
||||
'g.disclaimerShown'(val) {
|
||||
this.$q.localStorage.setItem('lnbits.disclaimerShown', val)
|
||||
},
|
||||
'g.locale'(val) {
|
||||
this.$q.localStorage.setItem('lnbits.lang', val)
|
||||
window.i18n.global.locale = val
|
||||
},
|
||||
'g.isFiatPriority'(val) {
|
||||
this.$q.localStorage.setItem('lnbits.isFiatPriority', val)
|
||||
},
|
||||
@@ -123,6 +127,13 @@ window.app.component('lnbits-theme', {
|
||||
if (this.g.mobileSimple === true) {
|
||||
document.body.classList.add('mobile-simple')
|
||||
}
|
||||
Object.entries(this.g.user.uiCustomization || {}).forEach(
|
||||
([key, value]) => {
|
||||
if (key in this.g) {
|
||||
this.g[key] = value
|
||||
}
|
||||
}
|
||||
)
|
||||
this.checkUrlParams()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -39,6 +39,15 @@ window.PageAccount = {
|
||||
color: 'pink-3'
|
||||
}
|
||||
],
|
||||
defaultSiteCustomisation: {
|
||||
locale: 'en',
|
||||
themeChoice: 'salvador',
|
||||
bgimageChoice: '',
|
||||
gradientChoice: true,
|
||||
darkChoice: true,
|
||||
borderChoice: 'hard-border',
|
||||
reactionChoice: 'confettiBothSides'
|
||||
},
|
||||
reactionOptions: [
|
||||
'None',
|
||||
'confettiBothSides',
|
||||
@@ -213,26 +222,18 @@ window.PageAccount = {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
activeLanguage(lang) {
|
||||
return window.i18n.global.locale === lang
|
||||
},
|
||||
changeLanguage(newValue) {
|
||||
window.i18n.global.locale = newValue
|
||||
this.$q.localStorage.set('lnbits.lang', newValue)
|
||||
},
|
||||
async updateAccount() {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'PUT',
|
||||
'/api/v1/auth/update',
|
||||
null,
|
||||
{
|
||||
user_id: this.g.user.id,
|
||||
username: this.g.user.username,
|
||||
email: this.g.user.email,
|
||||
extra: this.g.user.extra
|
||||
}
|
||||
)
|
||||
const {data} = await LNbits.api.request('PATCH', '/api/v1/auth', null, {
|
||||
user_id: this.g.user.id,
|
||||
username: this.g.user.username,
|
||||
email: this.g.user.email,
|
||||
extra: this.g.user.extra
|
||||
})
|
||||
this.untouchedUser = JSON.parse(JSON.stringify(this.g.user))
|
||||
this.hasUsername = !!data.username
|
||||
Quasar.Notify.create({
|
||||
@@ -680,6 +681,23 @@ window.PageAccount = {
|
||||
l => l.name !== label.name
|
||||
)
|
||||
})
|
||||
},
|
||||
|
||||
async siteCustomisationChanged(options = {}) {
|
||||
try {
|
||||
Object.entries(options || {}).forEach(([key, value]) => {
|
||||
if (key in this.g) {
|
||||
this.g[key] = value
|
||||
}
|
||||
})
|
||||
await LNbits.api.updateUiCustomization(options)
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: 'UI Customization updated.'
|
||||
})
|
||||
} catch (e) {
|
||||
LNbits.utils.notifyApiError(e)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -71,8 +71,11 @@
|
||||
<span>OFFLINE</span>
|
||||
</q-badge>
|
||||
|
||||
<lnbits-language-dropdown></lnbits-language-dropdown>
|
||||
<q-btn-dropdown v-if="g.user" flat rounded size="sm" class="q-pl-sm">
|
||||
<lnbits-language-dropdown
|
||||
@language-changed="handleLanguageChanged({locale: $event})"
|
||||
></lnbits-language-dropdown>
|
||||
|
||||
<q-btn-dropdown v-if="g.user" flat rounded size="md" class="q-pl-sm">
|
||||
<template v-slot:label>
|
||||
<q-avatar
|
||||
v-if="g.user?.extra?.picture && g.user?.extra?.picture !== ''"
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<template id="lnbits-language-dropdown">
|
||||
<q-btn-dropdown dense flat rounded size="sm" icon="language" class="q-pl-md">
|
||||
<q-btn-dropdown dense flat rounded size="md" class="q-pl-md">
|
||||
<template v-slot:label>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="currentLanguage.display"></q-item-label>
|
||||
<q-tooltip><span v-text="currentLanguage.label"></span></q-tooltip>
|
||||
</q-item-section>
|
||||
</template>
|
||||
<q-list v-for="(lang, index) in langs" :key="index">
|
||||
<q-item
|
||||
clickable
|
||||
|
||||
@@ -333,6 +333,16 @@
|
||||
class="q-mb-md"
|
||||
>
|
||||
</q-input>
|
||||
|
||||
<q-input
|
||||
v-model="g.user.extra.visible_wallet_count"
|
||||
:label="$t('visible_wallet_count')"
|
||||
filled
|
||||
dense
|
||||
type="number"
|
||||
class="q-mb-md"
|
||||
></q-input>
|
||||
|
||||
<q-input
|
||||
v-model="g.user.external_id"
|
||||
:label="$t('external_id')"
|
||||
@@ -380,22 +390,11 @@
|
||||
<span v-text="$t('language')"></span>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<lnbits-language-dropdown />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mb-md">
|
||||
<div class="col-4">
|
||||
<span v-text="$t('visible_wallet_count')"></span>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<q-input
|
||||
v-model="g.user.extra.visible_wallet_count"
|
||||
:label="$t('visible_wallet_count')"
|
||||
filled
|
||||
dense
|
||||
type="number"
|
||||
class="q-mb-md"
|
||||
></q-input>
|
||||
<lnbits-language-dropdown
|
||||
@language-changed="
|
||||
siteCustomisationChanged({locale: $event})
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -407,7 +406,9 @@
|
||||
<q-btn
|
||||
v-for="theme in themeOptions"
|
||||
:key="theme.name"
|
||||
@click="g.themeChoice = theme.name"
|
||||
@click="
|
||||
siteCustomisationChanged({themeChoice: theme.name})
|
||||
"
|
||||
:color="theme.color"
|
||||
dense
|
||||
flat
|
||||
@@ -427,6 +428,9 @@
|
||||
<q-input
|
||||
v-model="g.bgimageChoice"
|
||||
:label="$t('background_image')"
|
||||
@update:model-value="
|
||||
siteCustomisationChanged({bgimageChoice: $event})
|
||||
"
|
||||
>
|
||||
<q-tooltip
|
||||
><span v-text="$t('background_image')"></span
|
||||
@@ -445,6 +449,9 @@
|
||||
round
|
||||
icon="gradient"
|
||||
v-model="g.gradientChoice"
|
||||
@update:model-value="
|
||||
siteCustomisationChanged({gradientChoice: $event})
|
||||
"
|
||||
>
|
||||
<q-tooltip
|
||||
><span v-text="$t('toggle_gradient')"></span
|
||||
@@ -463,6 +470,9 @@
|
||||
flat
|
||||
round
|
||||
v-model="g.darkChoice"
|
||||
@update:model-value="
|
||||
siteCustomisationChanged({darkChoice: $event})
|
||||
"
|
||||
:icon="$q.dark.isActive ? 'brightness_3' : 'wb_sunny'"
|
||||
size="sm"
|
||||
>
|
||||
@@ -481,6 +491,9 @@
|
||||
v-model="g.borderChoice"
|
||||
:options="borderOptions"
|
||||
label="Borders"
|
||||
@update:model-value="
|
||||
siteCustomisationChanged({borderChoice: $event})
|
||||
"
|
||||
>
|
||||
<q-tooltip
|
||||
><span v-text="$t('border_choices')"></span
|
||||
@@ -508,6 +521,9 @@
|
||||
v-model="g.reactionChoice"
|
||||
:options="reactionOptions"
|
||||
label="Reactions"
|
||||
@update:model-value="
|
||||
siteCustomisationChanged({reactionChoice: $event})
|
||||
"
|
||||
>
|
||||
<q-tooltip
|
||||
><span v-text="$t('payment_reactions')"></span
|
||||
@@ -515,6 +531,17 @@
|
||||
</q-select>
|
||||
</div>
|
||||
</div>
|
||||
<q-card-section>
|
||||
<q-btn
|
||||
@click="
|
||||
siteCustomisationChanged(defaultSiteCustomisation)
|
||||
"
|
||||
:label="$t('reset_defaults')"
|
||||
filled
|
||||
color="primary"
|
||||
class="float-right q-mb-md"
|
||||
></q-btn>
|
||||
</q-card-section>
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="notifications">
|
||||
<q-card-section>
|
||||
|
||||
@@ -2016,9 +2016,7 @@ async def test_api_update_user_labels(http_client: AsyncClient):
|
||||
]
|
||||
data = UpdateUser(user_id=user.id, username=f"u{tiny_id}", extra=user.extra)
|
||||
assert data.extra
|
||||
response = await http_client.put(
|
||||
"/api/v1/auth/update?usr=" + user.id, json=data.dict()
|
||||
)
|
||||
response = await http_client.patch("/api/v1/auth?usr=" + user.id, json=data.dict())
|
||||
assert response.status_code == 200
|
||||
user_data = response.json()
|
||||
assert len(user_data["extra"]["labels"]) == 2
|
||||
@@ -2028,18 +2026,14 @@ async def test_api_update_user_labels(http_client: AsyncClient):
|
||||
assert user_data["extra"]["labels"][1]["color"] == "#00FF00"
|
||||
|
||||
data.extra.labels = []
|
||||
response = await http_client.put(
|
||||
"/api/v1/auth/update?usr=" + user.id, json=data.dict()
|
||||
)
|
||||
response = await http_client.patch("/api/v1/auth?usr=" + user.id, json=data.dict())
|
||||
assert response.status_code == 200
|
||||
user_data = response.json()
|
||||
assert len(user_data["extra"]["labels"]) == 0
|
||||
|
||||
json_data = data.dict()
|
||||
json_data["extra"] = {"labels": [{"name": "label + 01", "color": "#FF0000"}]}
|
||||
response = await http_client.put(
|
||||
"/api/v1/auth/update?usr=" + user.id, json=json_data
|
||||
)
|
||||
response = await http_client.patch("/api/v1/auth?usr=" + user.id, json=json_data)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
|
||||
Reference in New Issue
Block a user