Fix password fields; show stored users on login page
This commit is contained in:
parent
d4c8d69cf3
commit
4a5ada0bb0
|
|
@ -11,6 +11,7 @@ pub enum CallbackMessage {
|
||||||
|
|
||||||
pub enum CallbackMessageLogin {
|
pub enum CallbackMessageLogin {
|
||||||
PwAccepted {
|
PwAccepted {
|
||||||
|
user_index: i32,
|
||||||
username: SharedString,
|
username: SharedString,
|
||||||
password: SharedString,
|
password: SharedString,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@ use core::{cell::RefCell, ffi::CStr, ops::DerefMut};
|
||||||
use alloc::{boxed::Box, ffi::CString, format, rc::Rc, vec};
|
use alloc::{boxed::Box, ffi::CString, format, rc::Rc, vec};
|
||||||
use embassy_time::Instant;
|
use embassy_time::Instant;
|
||||||
use hex::FromHexError;
|
use hex::FromHexError;
|
||||||
use log::{info, warn};
|
use log::{error, info, warn};
|
||||||
use password_hash::Key;
|
use password_hash::Key;
|
||||||
use slint::SharedString;
|
use slint::{Model, ModelExt, ModelRc, SharedString, VecModel};
|
||||||
use spectre_api_sys::{SpectreAlgorithm, SpectreKeyID, SpectreUserKey};
|
use spectre_api_sys::{SpectreAlgorithm, SpectreKeyID, SpectreUserKey};
|
||||||
|
|
||||||
#[cfg(feature = "limit-fps")]
|
#[cfg(feature = "limit-fps")]
|
||||||
|
|
@ -33,7 +33,17 @@ pub mod window_adapter;
|
||||||
|
|
||||||
slint::include_modules!();
|
slint::include_modules!();
|
||||||
|
|
||||||
fn spectre_derive_user_key(username: &CStr, password: &CStr) -> SpectreUserKey {
|
fn spectre_derive_user_key(
|
||||||
|
username: &CStr,
|
||||||
|
password: &CStr,
|
||||||
|
encrypted_key: Option<Key>,
|
||||||
|
) -> SpectreUserKey {
|
||||||
|
if let Some(encrypted_key) = encrypted_key {
|
||||||
|
critical_section::with(|cs| {
|
||||||
|
ACTIVE_ENCRYPTED_USER_KEY.borrow(cs).set(encrypted_key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let user_key_start = Instant::now();
|
let user_key_start = Instant::now();
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
|
|
@ -177,13 +187,16 @@ struct State {
|
||||||
|
|
||||||
impl State {
|
impl State {
|
||||||
async fn new(db: AcidDatabase, main: AppWindow) -> Rc<RefCell<Self>> {
|
async fn new(db: AcidDatabase, main: AppWindow) -> Rc<RefCell<Self>> {
|
||||||
let state = Rc::new(RefCell::new(State {
|
let users = {
|
||||||
window: main.clone_strong(),
|
|
||||||
users: {
|
|
||||||
let users = Self::load_users(&db).await;
|
let users = Self::load_users(&db).await;
|
||||||
warn!("Users: {users:#?}");
|
warn!("Users: {users:#?}");
|
||||||
users
|
users
|
||||||
},
|
};
|
||||||
|
let usernames = users.users.clone().map(|user| user.username);
|
||||||
|
|
||||||
|
let state = Rc::new(RefCell::new(State {
|
||||||
|
window: main.clone_strong(),
|
||||||
|
users,
|
||||||
db: Rc::new(db),
|
db: Rc::new(db),
|
||||||
view: AppState::Login,
|
view: AppState::Login,
|
||||||
state_login: Default::default(),
|
state_login: Default::default(),
|
||||||
|
|
@ -195,7 +208,7 @@ impl State {
|
||||||
main.on_enter_view({
|
main.on_enter_view({
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
move |view| {
|
move |view| {
|
||||||
state.borrow_mut().set_view(view, true);
|
state.borrow_mut().set_view(view, true, true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -206,12 +219,18 @@ impl State {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
main.set_login_usernames(ModelRc::new(usernames));
|
||||||
|
|
||||||
main.on_login_pw_accepted({
|
main.on_login_pw_accepted({
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
move |username, password| {
|
move |user_index, username, password| {
|
||||||
State::process_callback_message(
|
State::process_callback_message(
|
||||||
&state,
|
&state,
|
||||||
CallbackMessage::Login(CallbackMessageLogin::PwAccepted { username, password }),
|
CallbackMessage::Login(CallbackMessageLogin::PwAccepted {
|
||||||
|
user_index,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -306,11 +325,7 @@ impl State {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_view(&mut self, view: AppState, reset: bool) {
|
fn reset_view(&mut self) {
|
||||||
self.view = view;
|
|
||||||
self.window.set_app_state(view);
|
|
||||||
|
|
||||||
if reset {
|
|
||||||
match self.view {
|
match self.view {
|
||||||
AppState::Login => self.state_login = Default::default(),
|
AppState::Login => self.state_login = Default::default(),
|
||||||
AppState::Users => self.state_users = Default::default(),
|
AppState::Users => self.state_users = Default::default(),
|
||||||
|
|
@ -318,6 +333,18 @@ impl State {
|
||||||
AppState::UserSites => self.state_user_sites = Default::default(),
|
AppState::UserSites => self.state_user_sites = Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn set_view(&mut self, view: AppState, reset_source: bool, reset_target: bool) {
|
||||||
|
if reset_source {
|
||||||
|
self.reset_view();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.view = view;
|
||||||
|
self.window.set_app_state(view);
|
||||||
|
|
||||||
|
if reset_target {
|
||||||
|
self.reset_view();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Instead of having a `loop` in the non-async `SlintBackend::run_event_loop`, we achieve
|
/// Instead of having a `loop` in the non-async `SlintBackend::run_event_loop`, we achieve
|
||||||
|
|
@ -349,14 +376,22 @@ struct StateLogin {}
|
||||||
impl AppViewTrait for StateLogin {
|
impl AppViewTrait for StateLogin {
|
||||||
fn process_callback_message(state_rc: &Rc<RefCell<State>>, message: CallbackMessage) {
|
fn process_callback_message(state_rc: &Rc<RefCell<State>>, message: CallbackMessage) {
|
||||||
let mut state = state_rc.borrow_mut();
|
let mut state = state_rc.borrow_mut();
|
||||||
if let CallbackMessage::Login(CallbackMessageLogin::PwAccepted { username, password }) =
|
if let CallbackMessage::Login(CallbackMessageLogin::PwAccepted {
|
||||||
message
|
user_index,
|
||||||
|
username: _,
|
||||||
|
password,
|
||||||
|
}) = message
|
||||||
{
|
{
|
||||||
|
let Some(user) = state.users.users.row_data(user_index as usize) else {
|
||||||
|
error!("Failed to find a user with index {user_index}.");
|
||||||
|
return;
|
||||||
|
};
|
||||||
let username_c =
|
let username_c =
|
||||||
CString::new(&*username).expect("Username cannot be converted to a C string.");
|
CString::new(&*user.username).expect("Username cannot be converted to a C string.");
|
||||||
let password_c =
|
let password_c =
|
||||||
CString::new(&*password).expect("Password cannot be converted to a C string.");
|
CString::new(&*password).expect("Password cannot be converted to a C string.");
|
||||||
let user_key = spectre_derive_user_key(&username_c, &password_c);
|
let user_key =
|
||||||
|
spectre_derive_user_key(&username_c, &password_c, Some(user.encrypted_key));
|
||||||
|
|
||||||
// let site_key_start = Instant::now();
|
// let site_key_start = Instant::now();
|
||||||
// let site_key = &*spectre_api_sys::spectre_site_key(
|
// let site_key = &*spectre_api_sys::spectre_site_key(
|
||||||
|
|
@ -373,22 +408,16 @@ impl AppViewTrait for StateLogin {
|
||||||
// );
|
// );
|
||||||
// TODO: Erase memory before freeing
|
// TODO: Erase memory before freeing
|
||||||
// __spre_free(site_key as *const _ as *mut _);
|
// __spre_free(site_key as *const _ as *mut _);
|
||||||
let Some(user) = state
|
|
||||||
.users
|
|
||||||
.users
|
|
||||||
.iter()
|
|
||||||
.find(|user| *user.username == *username)
|
|
||||||
else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
if user.key_id == user_key.keyID.bytes {
|
if user.key_id == user_key.keyID.bytes {
|
||||||
info!("Correct password entered for user {username:?}.");
|
info!("Correct password entered for user {:?}.", user.username);
|
||||||
state.state_user_sites = Some(StateUserSites { username, user_key });
|
state.state_user_sites = Some(StateUserSites {
|
||||||
state.set_view(AppState::UserSites, true);
|
username: user.username,
|
||||||
|
user_key,
|
||||||
|
});
|
||||||
|
state.set_view(AppState::UserSites, true, false);
|
||||||
} else {
|
} else {
|
||||||
warn!("Incorrect password entered for user {username:?}.");
|
warn!("Incorrect password entered for user {:?}.", user.username);
|
||||||
// TODO: Clear the input
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -402,7 +431,7 @@ impl AppViewTrait for StateUsers {
|
||||||
let mut state = state_rc.borrow_mut();
|
let mut state = state_rc.borrow_mut();
|
||||||
match message {
|
match message {
|
||||||
CallbackMessage::Escape => {
|
CallbackMessage::Escape => {
|
||||||
state.set_view(AppState::Login, false);
|
state.set_view(AppState::Login, true, false);
|
||||||
}
|
}
|
||||||
CallbackMessage::Users(CallbackMessageUsers::EditUser { username, new }) => {
|
CallbackMessage::Users(CallbackMessageUsers::EditUser { username, new }) => {
|
||||||
state.state_user_edit = StateUserEdit {
|
state.state_user_edit = StateUserEdit {
|
||||||
|
|
@ -412,7 +441,7 @@ impl AppViewTrait for StateUsers {
|
||||||
encrypted_key: None,
|
encrypted_key: None,
|
||||||
};
|
};
|
||||||
state.window.set_user_edit_username(username);
|
state.window.set_user_edit_username(username);
|
||||||
state.set_view(AppState::UserEdit, false);
|
state.set_view(AppState::UserEdit, true, false);
|
||||||
}
|
}
|
||||||
_ => (),
|
_ => (),
|
||||||
}
|
}
|
||||||
|
|
@ -433,7 +462,7 @@ impl AppViewTrait for StateUserEdit {
|
||||||
let mut state = state.borrow_mut();
|
let mut state = state.borrow_mut();
|
||||||
match message {
|
match message {
|
||||||
CallbackMessage::Escape => {
|
CallbackMessage::Escape => {
|
||||||
state.set_view(AppState::Users, false);
|
state.set_view(AppState::Users, true, false);
|
||||||
}
|
}
|
||||||
CallbackMessage::UserEdit(CallbackMessageUserEdit::ComputeIdenticon { password }) => {
|
CallbackMessage::UserEdit(CallbackMessageUserEdit::ComputeIdenticon { password }) => {
|
||||||
let username_c = CString::new(&*state.state_user_edit.username)
|
let username_c = CString::new(&*state.state_user_edit.username)
|
||||||
|
|
@ -493,14 +522,11 @@ impl AppViewTrait for StateUserEdit {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
critical_section::with(|cs| {
|
|
||||||
ACTIVE_ENCRYPTED_USER_KEY.borrow(cs).set(key);
|
|
||||||
});
|
|
||||||
let username_c = CString::new(&*state.state_user_edit.username)
|
let username_c = CString::new(&*state.state_user_edit.username)
|
||||||
.expect("Username cannot be converted to a C string.");
|
.expect("Username cannot be converted to a C string.");
|
||||||
let password_c =
|
let password_c =
|
||||||
CString::new(&**password).expect("Password cannot be converted to a C string.");
|
CString::new(&**password).expect("Password cannot be converted to a C string.");
|
||||||
let user_key = spectre_derive_user_key(&username_c, &password_c);
|
let user_key = spectre_derive_user_key(&username_c, &password_c, Some(key));
|
||||||
|
|
||||||
state.window.set_user_edit_key_error(SharedString::new());
|
state.window.set_user_edit_key_error(SharedString::new());
|
||||||
state.window.set_user_edit_key_id(
|
state.window.set_user_edit_key_id(
|
||||||
|
|
@ -526,17 +552,30 @@ impl AppViewTrait for StateUserEdit {
|
||||||
warn!("Encrypted key is not set.");
|
warn!("Encrypted key is not set.");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
// If a user with that username already exists, overwrite it.
|
// If a user with that username already exists, overwrite it.
|
||||||
let username = state.state_user_edit.username.clone();
|
let user = SpectreUserConfig {
|
||||||
state
|
username: state.state_user_edit.username.clone(),
|
||||||
.users
|
|
||||||
.users
|
|
||||||
.retain(|user| &*user.username != &*username);
|
|
||||||
state.users.users.push(SpectreUserConfig {
|
|
||||||
username,
|
|
||||||
encrypted_key,
|
encrypted_key,
|
||||||
key_id,
|
key_id,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
let mut existing_index = None;
|
||||||
|
|
||||||
|
for index in 0..state.users.users.row_count() {
|
||||||
|
if let Some(current_user) = state.users.users.row_data(index) {
|
||||||
|
if current_user.username == user.username {
|
||||||
|
existing_index = Some(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(existing_index) = existing_index {
|
||||||
|
state.users.users.set_row_data(existing_index, user);
|
||||||
|
} else {
|
||||||
|
state.users.users.push(user);
|
||||||
|
}
|
||||||
|
|
||||||
slint::spawn_local({
|
slint::spawn_local({
|
||||||
let state_rc = state_rc.clone();
|
let state_rc = state_rc.clone();
|
||||||
let db = state.db.clone();
|
let db = state.db.clone();
|
||||||
|
|
@ -553,7 +592,7 @@ impl AppViewTrait for StateUserEdit {
|
||||||
}
|
}
|
||||||
CallbackMessage::UserEdit(CallbackMessageUserEdit::ConfirmProcessed) => {
|
CallbackMessage::UserEdit(CallbackMessageUserEdit::ConfirmProcessed) => {
|
||||||
state.state_user_edit = Default::default();
|
state.state_user_edit = Default::default();
|
||||||
state.set_view(AppState::Users, true);
|
state.set_view(AppState::Users, true, true);
|
||||||
}
|
}
|
||||||
_ => (),
|
_ => (),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,34 @@
|
||||||
use alloc::{string::String, vec::Vec};
|
use core::fmt::{Debug, Formatter};
|
||||||
|
|
||||||
|
use alloc::{rc::Rc, string::String, vec::Vec};
|
||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
use password_hash::Key;
|
use password_hash::Key;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use slint::SharedString;
|
use slint::{Model, ModelRc, SharedString, VecModel};
|
||||||
use spectre_api_sys::{SpectreAlgorithm, SpectreCounter, SpectreResultType};
|
use spectre_api_sys::{SpectreAlgorithm, SpectreCounter, SpectreResultType};
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Default, Clone, Debug, PartialEq, Eq)]
|
#[derive(Deserialize, Serialize, Default)]
|
||||||
pub struct SpectreUsersConfig {
|
pub struct SpectreUsersConfig {
|
||||||
pub users: Vec<SpectreUserConfig>,
|
#[serde(with = "vec_model")]
|
||||||
|
pub users: Rc<VecModel<SpectreUserConfig>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Debug for SpectreUsersConfig {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
|
||||||
|
f.debug_struct("SpectreUsersConfig")
|
||||||
|
.field_with("users", |f| {
|
||||||
|
f.debug_list().entries(self.users.iter()).finish()
|
||||||
|
})
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clone for SpectreUsersConfig {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
users: Rc::new(self.users.iter().collect()),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)]
|
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)]
|
||||||
|
|
@ -57,6 +78,33 @@ pub struct SpectreSite {
|
||||||
pub config: SpectreSiteConfig,
|
pub config: SpectreSiteConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mod vec_model {
|
||||||
|
use alloc::{rc::Rc, vec::Vec};
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
|
use slint::{Model, VecModel};
|
||||||
|
|
||||||
|
#[allow(unused)]
|
||||||
|
pub fn serialize<T, S>(value: &Rc<VecModel<T>>, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
T: Serialize,
|
||||||
|
VecModel<T>: Model<Data = T>,
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
let vec: Vec<T> = value.iter().collect();
|
||||||
|
vec.serialize(serializer)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unused)]
|
||||||
|
pub fn deserialize<'de, T, D>(deserializer: D) -> Result<Rc<VecModel<T>>, D::Error>
|
||||||
|
where
|
||||||
|
T: Deserialize<'de>,
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let vec = Vec::<T>::deserialize(deserializer)?;
|
||||||
|
Ok(Rc::new(VecModel::from(vec)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
mod with_repr {
|
mod with_repr {
|
||||||
use serde::{Deserialize, Deserializer, Serializer};
|
use serde::{Deserialize, Deserializer, Serializer};
|
||||||
use spectre_api_sys::{SpectreAlgorithm, SpectreResultType};
|
use spectre_api_sys::{SpectreAlgorithm, SpectreResultType};
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ export component LoginView inherits VerticalLayout {
|
||||||
spacing: Style.spacing;
|
spacing: Style.spacing;
|
||||||
in property <[string]> usernames <=> combo_box_username.model;
|
in property <[string]> usernames <=> combo_box_username.model;
|
||||||
callback users_clicked();
|
callback users_clicked();
|
||||||
callback pw_accepted(string, string);
|
callback pw_accepted(int, string, string);
|
||||||
Rectangle { }
|
Rectangle { }
|
||||||
|
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
|
|
@ -25,14 +25,16 @@ export component LoginView inherits VerticalLayout {
|
||||||
input-type: InputType.password;
|
input-type: InputType.password;
|
||||||
placeholder-text: "Password";
|
placeholder-text: "Password";
|
||||||
accepted(text) => {
|
accepted(text) => {
|
||||||
root.pw_accepted(combo_box_username.current-value, text);
|
root.pw_accepted(combo_box_username.current-index, combo_box_username.current-value, text);
|
||||||
|
line_edit_user_pw.text = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
IconButton {
|
IconButton {
|
||||||
icon: @image-url("images/log-in.svg");
|
icon: @image-url("images/log-in.svg");
|
||||||
clicked => {
|
clicked => {
|
||||||
root.pw_accepted(combo_box_username.current-value, line_edit_user_pw.text);
|
root.pw_accepted(combo_box_username.current-index, combo_box_username.current-value, line_edit_user_pw.text);
|
||||||
|
line_edit_user_pw.text = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export enum AppState {
|
||||||
|
|
||||||
export component AppWindow inherits Window {
|
export component AppWindow inherits Window {
|
||||||
// Special characters to generate pre-render glyphs for.
|
// Special characters to generate pre-render glyphs for.
|
||||||
in property <string> dummy: "ÄÖÜÁÉÍÓÚÝŔŚĹŹĆŃĚĽŽŠČŘĎŤŇŮÅäöüáéíóúýŕśĺźćńěľžščřďťňůåß„“”‘’—–@&$%+=¡¿¢£$¥€²³¼½¬¤¦§©®™°´ˇ¨";
|
in property <string> dummy: "ÄÖÜÁÉÍÓÚÝŔŚĹŹĆŃĚĽŽŠČŘĎŤŇŮÅäöüáéíóúýŕśĺźćńěľžščřďťňůåß„“”‘’—–@&$%+=¡¿¢£$¥€²³¼½¬¤¦§©®™°´ˇ¨●";
|
||||||
in property <string> dummy_identicon_symbols: "╔╚╰═█░▒▓☺☻╗╝╯═◈◎◐◑◒◓☀☁☂☃☄★☆☎☏⎈⌂☘☢☣☕⌚⌛⏰⚡⛄⛅☔♔♕♖♗♘♙♚♛♜♝♞♟♨♩♪♫⚐⚑⚔⚖⚙⚠⌘⏎✄✆✈✉✌";
|
in property <string> dummy_identicon_symbols: "╔╚╰═█░▒▓☺☻╗╝╯═◈◎◐◑◒◓☀☁☂☃☄★☆☎☏⎈⌂☘☢☣☕⌚⌛⏰⚡⛄⛅☔♔♕♖♗♘♙♚♛♜♝♞♟♨♩♪♫⚐⚑⚔⚖⚙⚠⌘⏎✄✆✈✉✌";
|
||||||
default-font-family: "IBM Plex Mono";
|
default-font-family: "IBM Plex Mono";
|
||||||
default-font-size: 16pt;
|
default-font-size: 16pt;
|
||||||
|
|
@ -38,6 +38,7 @@ export component AppWindow inherits Window {
|
||||||
callback escape();
|
callback escape();
|
||||||
callback enter-view(view: AppState);
|
callback enter-view(view: AppState);
|
||||||
// Login View
|
// Login View
|
||||||
|
in property <[string]> login_usernames <=> login_view.usernames;
|
||||||
callback login_pw_accepted <=> login_view.pw_accepted;
|
callback login_pw_accepted <=> login_view.pw_accepted;
|
||||||
// Users View
|
// Users View
|
||||||
callback users_edit_user <=> users_view.edit_user;
|
callback users_edit_user <=> users_view.edit_user;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue