ESP32: экран подтверждения регистрации
This commit is contained in:
parent
b83543d018
commit
0c9afea67a
@ -13,11 +13,12 @@
|
||||
7. Открыть `Кошелёк`, нажать `Проверить` и убедиться, что баланс реально читается из `Solana RPC`; затем открыть `QR и URI` и проверить, что QR-код отрисовывается и сканируется как `solana:`-ссылка.
|
||||
8. При необходимости отдельно проверить тестовые кнопки `+/- SOL`: они меняют локальный баланс для UX-сценариев, но после следующей реальной RPC-проверки баланс должен вернуться к сетевому значению.
|
||||
9. Вернуться на главный экран и проверить, что до выполнения всех условий кнопка регистрации недоступна, а после выполнения становится доступной; также убедиться, что между двумя нижними кнопками есть небольшой зазор.
|
||||
10. Нажать кнопку регистрации и убедиться, что она запускает on-chain flow сразу без отдельного confirm-экрана, а после завершения статус меняется на `Homeserver активен`, онлайн-статус становится активным, и на экране аккаунта появляются краткие отпечатки `PDA/TX`.
|
||||
11. После регистрации проверить через `Solana`/UI проекта, что `user_pda` для этого логина реально создана, сохранена в `NVS` и соответствует `device`-адресу устройства.
|
||||
12. Открыть `Запросы`, поочерёдно открыть оба демонстрационных запроса и проверить, что кнопки `Разрешить` и `Отклонить` меняют их статус.
|
||||
13. При необходимости открыть `Настройки -> Сменить PIN` и убедиться, что новый PIN сохраняется, хотя вход по PIN временно не используется на старте.
|
||||
14. Выполнить `Полный сброс` и убедиться, что все поля, секрет, баланс, онлайн и регистрация очищаются.
|
||||
10. Нажать кнопку регистрации и убедиться, что открывается отдельный экран проверки, где ещё раз видно `login`, статус свободного `PDA`, баланс, `homeserver1` с пометкой о стандартном значении и сообщение, если `Wi-Fi` не подключён.
|
||||
11. На экране проверки нажать `ЗАРЕГИСТРИРОВАТЬ В СИЯНИИ` и убедиться, что после этого появляется отдельный экран результата с успехом либо подробной ошибкой.
|
||||
12. После успешной регистрации проверить через `Solana`/UI проекта, что `user_pda` для этого логина реально создана, сохранена в `NVS` и соответствует `device`-адресу устройства, а `tx signature` тоже сохранён.
|
||||
13. Открыть `Запросы`, поочерёдно открыть оба демонстрационных запроса и проверить, что кнопки `Разрешить` и `Отклонить` меняют их статус.
|
||||
14. При необходимости открыть `Настройки -> Сменить PIN` и убедиться, что новый PIN сохраняется, хотя вход по PIN временно не используется на старте.
|
||||
15. Выполнить `Полный сброс` и убедиться, что все поля, секрет, баланс, онлайн и регистрация очищаются.
|
||||
|
||||
- ожидаемый результат:
|
||||
новый `ESP32`-скетч стабильно запускается, показывает читаемый интерфейс хотя бы в ASCII-транслитерации, сохраняет данные во внутренней памяти устройства, реально подключается к `Wi-Fi`, реально проверяет `API/RPC/WS`, реально читает баланс из `Solana RPC`, рисует рабочий `QR` для `solana:`-URI и позволяет вручную пройти полный сценарий on-chain регистрации homeserver.
|
||||
|
||||
@ -84,7 +84,8 @@ enum Screen {
|
||||
SCREEN_SECRET_GENERATE_RUNNING,
|
||||
SCREEN_SECRET_GENERATE_CANCEL_CONFIRM,
|
||||
SCREEN_TEXT_EDIT,
|
||||
SCREEN_REGISTER_ACCOUNT_PLACEHOLDER,
|
||||
SCREEN_REGISTER_ACCOUNT_CONFIRM,
|
||||
SCREEN_REGISTER_ACCOUNT_RESULT,
|
||||
};
|
||||
|
||||
enum SwipeDirection {
|
||||
@ -123,6 +124,7 @@ enum ActionId {
|
||||
ACTION_BACK_ACCOUNT,
|
||||
ACTION_REFRESH_BALANCE,
|
||||
ACTION_REGISTER_ACCOUNT,
|
||||
ACTION_REGISTER_ACCOUNT_EXECUTE,
|
||||
ACTION_EDITOR_SAVE,
|
||||
ACTION_EDITOR_CANCEL,
|
||||
};
|
||||
@ -246,6 +248,14 @@ static bool gShowRegisterAccountButton = false;
|
||||
static String gUserPdaAddress;
|
||||
static String gRegistrationSignature;
|
||||
static String gShineStatusLine = "SHiNE: account not configured";
|
||||
static String gRegisterConfirmMessage;
|
||||
static String gRegisterConfirmBalanceLine;
|
||||
static String gRegisterConfirmPdaLine;
|
||||
static String gRegisterConfirmHomeserverLine;
|
||||
static bool gRegisterConfirmCanSubmit = false;
|
||||
static String gRegisterResultMessage;
|
||||
static String gRegisterResultDetails;
|
||||
static bool gRegisterResultSuccess = false;
|
||||
static String gShineSessionId;
|
||||
static String gShineSessionKey;
|
||||
static String gShineStoragePwd;
|
||||
@ -340,6 +350,7 @@ static String shineWsUrl();
|
||||
static String shineHomeLine();
|
||||
static String balanceHomeLine();
|
||||
static uint64_t shineNowMs();
|
||||
static bool loadWalletBalanceLamports(uint64_t &lamportsOut, String &messageOut);
|
||||
static void shortVecEncode(size_t value, std::vector<uint8_t> &out);
|
||||
static void pushU32LE(std::vector<uint8_t> &out, uint32_t value);
|
||||
static void pushU64LE(std::vector<uint8_t> &out, uint64_t value);
|
||||
@ -385,6 +396,7 @@ static bool signMessageEd25519(const std::vector<uint8_t> &message, const uint8_
|
||||
static String encodeTransactionBase64(const uint8_t signature[64], const std::vector<uint8_t> &message);
|
||||
static bool awaitTransactionConfirmation(const String &signatureB58, String &messageOut);
|
||||
static bool registerHomeserverOnSolana(String &messageOut);
|
||||
static void prepareRegisterAccountScreen();
|
||||
static String buildSessionKeyStringFromPublicBase64(const String &pubB64);
|
||||
static bool deriveKeypairFromSeed32(const uint8_t seed32[32], uint8_t pub32[32], uint8_t sec64[64]);
|
||||
static bool deriveSeedKeypairFromBase58(const String &seedB58, uint8_t seed32[32], uint8_t pub32[32], uint8_t sec64[64]);
|
||||
@ -846,6 +858,14 @@ static void markAccountStateDirty() {
|
||||
gShowRegisterAccountButton = false;
|
||||
gUserPdaAddress = "";
|
||||
gRegistrationSignature = "";
|
||||
gRegisterConfirmMessage = "";
|
||||
gRegisterConfirmBalanceLine = "";
|
||||
gRegisterConfirmPdaLine = "";
|
||||
gRegisterConfirmHomeserverLine = "";
|
||||
gRegisterConfirmCanSubmit = false;
|
||||
gRegisterResultMessage = "";
|
||||
gRegisterResultDetails = "";
|
||||
gRegisterResultSuccess = false;
|
||||
clearShineSessionState(true);
|
||||
gShineStatusLine = "SHiNE: account not configured";
|
||||
}
|
||||
@ -947,15 +967,26 @@ static String formatSolValue(uint64_t lamports) {
|
||||
}
|
||||
|
||||
static bool refreshWalletBalance(String &messageOut) {
|
||||
messageOut = "";
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
gBalanceStatusMessage = "Balance: Wi-Fi disconnected";
|
||||
uint64_t lamports = 0;
|
||||
if (!loadWalletBalanceLamports(lamports, messageOut)) {
|
||||
gBalanceStatusMessage = messageOut;
|
||||
return false;
|
||||
}
|
||||
|
||||
gBalanceStatusMessage = formatSolValue(lamports);
|
||||
messageOut = gBalanceStatusMessage;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool loadWalletBalanceLamports(uint64_t &lamportsOut, String &messageOut) {
|
||||
messageOut = "";
|
||||
lamportsOut = 0;
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
messageOut = "Баланс: Wi-Fi не подключен";
|
||||
return false;
|
||||
}
|
||||
if (gDevicePubB58.isEmpty()) {
|
||||
gBalanceStatusMessage = "Balance: secret not set";
|
||||
messageOut = gBalanceStatusMessage;
|
||||
messageOut = "Баланс: секрет не задан";
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -963,20 +994,15 @@ static bool refreshWalletBalance(String &messageOut) {
|
||||
String payload;
|
||||
String req = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"getBalance\",\"params\":[\"" + gDevicePubB58 + "\",{\"commitment\":\"confirmed\"}]}";
|
||||
if (!httpPostJson(gSolanaRpcUrl, req, code, payload)) {
|
||||
gBalanceStatusMessage = "Balance: failed to load";
|
||||
messageOut = gBalanceStatusMessage;
|
||||
messageOut = "Баланс: RPC не ответил";
|
||||
return false;
|
||||
}
|
||||
|
||||
uint64_t lamports = 0;
|
||||
if (!jsonInt64Field(payload, "value", lamports)) {
|
||||
gBalanceStatusMessage = "Balance: failed to load";
|
||||
messageOut = gBalanceStatusMessage;
|
||||
if (!jsonInt64Field(payload, "value", lamportsOut)) {
|
||||
messageOut = "Баланс: не удалось прочитать";
|
||||
return false;
|
||||
}
|
||||
|
||||
gBalanceStatusMessage = formatSolValue(lamports);
|
||||
messageOut = gBalanceStatusMessage;
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -1525,6 +1551,76 @@ static bool registerHomeserverOnSolana(String &messageOut) {
|
||||
return true;
|
||||
}
|
||||
|
||||
static void prepareRegisterAccountScreen() {
|
||||
gRegisterResultMessage = "";
|
||||
gRegisterResultDetails = "";
|
||||
gRegisterResultSuccess = false;
|
||||
gRegisterConfirmMessage = "";
|
||||
gRegisterConfirmBalanceLine = "";
|
||||
gRegisterConfirmPdaLine = "";
|
||||
gRegisterConfirmHomeserverLine = "";
|
||||
gRegisterConfirmCanSubmit = false;
|
||||
|
||||
String cleanLogin = normalizeLoginValue(gLoginValue);
|
||||
if (cleanLogin.isEmpty()) {
|
||||
gRegisterConfirmMessage = "Логин не задан";
|
||||
return;
|
||||
}
|
||||
if (!gSecretConfigured) {
|
||||
gRegisterConfirmMessage = "Секрет не задан";
|
||||
return;
|
||||
}
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
gRegisterConfirmMessage = "Wi-Fi не подключен";
|
||||
return;
|
||||
}
|
||||
|
||||
String balanceMessage;
|
||||
uint64_t lamports = 0;
|
||||
if (!loadWalletBalanceLamports(lamports, balanceMessage)) {
|
||||
gRegisterConfirmMessage = balanceMessage;
|
||||
} else {
|
||||
gRegisterConfirmBalanceLine = formatSolValue(lamports);
|
||||
if (lamports < 20000000ULL) {
|
||||
gRegisterConfirmMessage = "Баланс меньше 0.020 SOL";
|
||||
} else {
|
||||
gRegisterConfirmMessage = "Баланс подходит: 0.020 SOL или выше";
|
||||
}
|
||||
}
|
||||
|
||||
if (gHomeserverValue.isEmpty()) {
|
||||
gRegisterConfirmHomeserverLine = "Homeserver не задан";
|
||||
} else if (gHomeserverValue == "homeserver1") {
|
||||
gRegisterConfirmHomeserverLine = "Homeserver: homeserver1 (стандартное значение)";
|
||||
} else {
|
||||
gRegisterConfirmHomeserverLine = String("Homeserver: ") + gHomeserverValue;
|
||||
}
|
||||
|
||||
ShinePdaUserState pdaState;
|
||||
String pdaError;
|
||||
if (!readShineUserPda(cleanLogin, pdaState, pdaError)) {
|
||||
gRegisterConfirmPdaLine = pdaError.isEmpty() ? "PDA: не удалось проверить" : String("PDA: ") + pdaError;
|
||||
if (gRegisterConfirmMessage.isEmpty()) {
|
||||
gRegisterConfirmMessage = pdaError.isEmpty() ? "Не удалось проверить PDA" : pdaError;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (pdaState.found) {
|
||||
gRegisterConfirmPdaLine = "PDA уже занят, этот login не свободен";
|
||||
if (gRegisterConfirmMessage.isEmpty()) {
|
||||
gRegisterConfirmMessage = "Login уже зарегистрирован";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
gRegisterConfirmPdaLine = "PDA свободен для регистрации";
|
||||
if (gRegisterConfirmMessage.isEmpty()) {
|
||||
gRegisterConfirmMessage = "Все проверки пройдены";
|
||||
}
|
||||
gRegisterConfirmCanSubmit = true;
|
||||
}
|
||||
|
||||
static bool parseShineUserPdaBytes(const std::vector<uint8_t> &bytes, ShinePdaUserState &outState, String &errorOut) {
|
||||
outState = ShinePdaUserState{};
|
||||
errorOut = "";
|
||||
@ -2982,19 +3078,30 @@ static void actionButtonCb(lv_event_t *event) {
|
||||
showScreen(SCREEN_SETTINGS_MENU);
|
||||
break;
|
||||
case ACTION_REGISTER_ACCOUNT:
|
||||
gAccountStatusMessage = "Регистрация запущена...";
|
||||
gShineStatusLine = "SHiNE: регистрация запущена";
|
||||
{
|
||||
prepareRegisterAccountScreen();
|
||||
showScreen(SCREEN_REGISTER_ACCOUNT_CONFIRM);
|
||||
break;
|
||||
case ACTION_REGISTER_ACCOUNT_EXECUTE: {
|
||||
String registerMessage;
|
||||
if (registerHomeserverOnSolana(registerMessage)) {
|
||||
gAccountStatusMessage = registerMessage;
|
||||
gRegisterResultSuccess = true;
|
||||
gRegisterResultMessage = "Регистрация в Сиянии завершена";
|
||||
gRegisterResultDetails = registerMessage;
|
||||
gAccountStatusMessage = "Регистрация завершена";
|
||||
} else {
|
||||
gRegisterResultSuccess = false;
|
||||
gRegisterResultMessage = "Регистрация не выполнена";
|
||||
gRegisterResultDetails = registerMessage;
|
||||
gAccountStatusMessage = registerMessage;
|
||||
gShineStatusLine = String("SHiNE: ") + registerMessage;
|
||||
}
|
||||
rebuildScreen();
|
||||
}
|
||||
gRegisterConfirmMessage = "";
|
||||
gRegisterConfirmBalanceLine = "";
|
||||
gRegisterConfirmPdaLine = "";
|
||||
gRegisterConfirmHomeserverLine = "";
|
||||
gRegisterConfirmCanSubmit = false;
|
||||
showScreen(SCREEN_REGISTER_ACCOUNT_RESULT);
|
||||
break;
|
||||
}
|
||||
case ACTION_OPEN_WIFI:
|
||||
gWifiViewMode = WIFI_VIEW_OVERVIEW;
|
||||
showScreen(SCREEN_WIFI);
|
||||
@ -3525,12 +3632,50 @@ static void drawSecretGenerateCancelConfirmScreen() {
|
||||
makeVersionTag();
|
||||
}
|
||||
|
||||
static void drawRegisterAccountPlaceholderScreen() {
|
||||
static void drawRegisterAccountConfirmScreen() {
|
||||
setRootStyle();
|
||||
makeTitle("REGISTER ACCOUNT", 22, &lv_font_montserrat_24);
|
||||
makeBody("Registration now starts directly from the home screen button.", 112, 420);
|
||||
makeBody("This screen is kept only as a fallback status page.", 156, 420);
|
||||
makeButton("BACK", 140, 360, 200, 72, 0x5A6570, ACTION_BACK_HOME, &lv_font_montserrat_22);
|
||||
String topLine = gRegisterConfirmMessage.isEmpty() ? String("Проверка регистрации") : gRegisterConfirmMessage;
|
||||
makeBody(topLine.c_str(), 96, 420);
|
||||
if (!gRegisterConfirmPdaLine.isEmpty()) {
|
||||
makeBody(gRegisterConfirmPdaLine.c_str(), 138, 420);
|
||||
}
|
||||
if (!gRegisterConfirmBalanceLine.isEmpty()) {
|
||||
makeBody(gRegisterConfirmBalanceLine.c_str(), 180, 420);
|
||||
}
|
||||
if (!gRegisterConfirmHomeserverLine.isEmpty()) {
|
||||
makeBody(gRegisterConfirmHomeserverLine.c_str(), 222, 420);
|
||||
}
|
||||
if (gRegisterConfirmCanSubmit) {
|
||||
makeButton("ЗАРЕГИСТРИРОВАТЬ В СИЯНИИ", 22, 296, 436, 74, 0x2A9D8F, ACTION_REGISTER_ACCOUNT_EXECUTE, &lv_font_montserrat_18);
|
||||
} else {
|
||||
makeButton("НЕДОСТУПНО", 22, 296, 436, 74, 0x4A5560, ACTION_NONE, &lv_font_montserrat_20);
|
||||
}
|
||||
makeButton("BACK", 140, 384, 200, 54, 0x5A6570, ACTION_BACK_HOME, &lv_font_montserrat_20);
|
||||
makeVersionTag();
|
||||
}
|
||||
|
||||
static void drawRegisterAccountResultScreen() {
|
||||
setRootStyle();
|
||||
makeTitle("REGISTER RESULT", 22, &lv_font_montserrat_24);
|
||||
String resultTopLine = gRegisterResultSuccess ? String("Регистрация завершилась успешно") : String("Регистрация завершилась с ошибкой");
|
||||
makeBody(resultTopLine.c_str(), 96, 420);
|
||||
makeBody(gRegisterResultMessage.c_str(), 140, 420);
|
||||
if (!gRegisterResultDetails.isEmpty()) {
|
||||
makeBody(gRegisterResultDetails.c_str(), 184, 420);
|
||||
}
|
||||
if (gRegisterResultSuccess) {
|
||||
if (!gUserPdaAddress.isEmpty()) {
|
||||
String pdaLine = String("user_pda: ") + abbreviateValue(gUserPdaAddress, 12, 8);
|
||||
makeBody(pdaLine.c_str(), 228, 420);
|
||||
}
|
||||
if (!gRegistrationSignature.isEmpty()) {
|
||||
String txLine = String("tx: ") + abbreviateValue(gRegistrationSignature, 12, 8);
|
||||
makeBody(txLine.c_str(), 270, 420);
|
||||
}
|
||||
}
|
||||
makeButton("BACK HOME", 22, 372, 200, 58, 0x5A6570, ACTION_BACK_HOME, &lv_font_montserrat_20);
|
||||
makeButton("ACCOUNT", 258, 372, 200, 58, 0x2A6F97, ACTION_OPEN_ACCOUNT, &lv_font_montserrat_20);
|
||||
makeVersionTag();
|
||||
}
|
||||
|
||||
@ -3694,8 +3839,11 @@ static void rebuildScreen() {
|
||||
case SCREEN_TEXT_EDIT:
|
||||
drawTextEditScreen();
|
||||
break;
|
||||
case SCREEN_REGISTER_ACCOUNT_PLACEHOLDER:
|
||||
drawRegisterAccountPlaceholderScreen();
|
||||
case SCREEN_REGISTER_ACCOUNT_CONFIRM:
|
||||
drawRegisterAccountConfirmScreen();
|
||||
break;
|
||||
case SCREEN_REGISTER_ACCOUNT_RESULT:
|
||||
drawRegisterAccountResultScreen();
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -3821,7 +3969,8 @@ static void handleSwipe(SwipeDirection swipe) {
|
||||
case SCREEN_TEXT_EDIT:
|
||||
handleTextEditSwipe(swipe);
|
||||
break;
|
||||
case SCREEN_REGISTER_ACCOUNT_PLACEHOLDER:
|
||||
case SCREEN_REGISTER_ACCOUNT_CONFIRM:
|
||||
case SCREEN_REGISTER_ACCOUNT_RESULT:
|
||||
handleHomeSwipe(swipe);
|
||||
break;
|
||||
}
|
||||
|
||||
@ -102,6 +102,8 @@
|
||||
13. `PIN_EDIT`
|
||||
14. `TEXT_INPUT`
|
||||
15. `CONFIRM`
|
||||
16. `REGISTER_ACCOUNT_CONFIRM`
|
||||
17. `REGISTER_ACCOUNT_RESULT`
|
||||
|
||||
## Общие правила интерфейса
|
||||
|
||||
@ -166,6 +168,52 @@
|
||||
- вместо призыва к регистрации показывается статус `Homeserver активен`.
|
||||
- две нижние кнопки внизу экрана не прилегают вплотную друг к другу, между ними есть небольшой зазор.
|
||||
|
||||
## Экран REGISTER_ACCOUNT_CONFIRM
|
||||
|
||||
Показывает предварительную проверку перед отправкой транзакции регистрации.
|
||||
|
||||
Отображается:
|
||||
|
||||
- повторный `login`;
|
||||
- сообщение о том, что логин свободен или уже занят;
|
||||
- баланс кошелька с проверкой порога `0.020 SOL`;
|
||||
- имя homeserver;
|
||||
- пометка, если используется стандартное значение `homeserver1`;
|
||||
- отдельное предупреждение, если `Wi-Fi` не подключён.
|
||||
|
||||
Кнопки:
|
||||
|
||||
- `ЗАРЕГИСТРИРОВАТЬ В СИЯНИИ`
|
||||
- `BACK`
|
||||
|
||||
Поведение:
|
||||
|
||||
- если `Wi-Fi` не подключён, на экране прямо показывается уведомление об этом и кнопка регистрации недоступна;
|
||||
- если `login` уже занят в `shine_users`, регистрация недоступна;
|
||||
- если баланс меньше `0.020 SOL`, на экране показывается соответствующая ошибка;
|
||||
- если все проверки пройдены, кнопка регистрации активна и запускает on-chain регистрацию.
|
||||
|
||||
## Экран REGISTER_ACCOUNT_RESULT
|
||||
|
||||
Показывает результат отправки регистрации.
|
||||
|
||||
Отображается:
|
||||
|
||||
- текст успеха или ошибки;
|
||||
- подробное сообщение;
|
||||
- при успехе краткий `user_pda`;
|
||||
- при успехе краткий `tx signature`.
|
||||
|
||||
Кнопки:
|
||||
|
||||
- `BACK HOME`
|
||||
- `ACCOUNT`
|
||||
|
||||
Поведение:
|
||||
|
||||
- после успешной регистрации данные `user_pda` и `tx signature` сохраняются в `NVS`;
|
||||
- при ошибке на экране показывается причина отказа.
|
||||
|
||||
## Экран STATUS
|
||||
|
||||
Показывает сводку:
|
||||
@ -441,7 +489,10 @@ QR должен быть сканируемым, а не декоративны
|
||||
11. при необходимости пополнить баланс;
|
||||
12. вернуться на `HOME`;
|
||||
13. нажать `REGISTER ACCOUNT`;
|
||||
14. после завершения увидеть статус `Homeserver активен`.
|
||||
14. на экране проверки ещё раз увидеть `login`, статус свободного `PDA`, баланс, `homeserver1` и при необходимости сообщение о неподключённом `Wi-Fi`;
|
||||
15. нажать `ЗАРЕГИСТРИРОВАТЬ В СИЯНИИ`;
|
||||
16. после завершения увидеть либо экран успеха с `user_pda` и `tx signature`, либо подробную ошибку;
|
||||
17. после успешной регистрации увидеть статус `Homeserver активен`.
|
||||
|
||||
Примечание:
|
||||
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
client.version=1.2.164
|
||||
server.version=1.2.153
|
||||
client.version=1.2.165
|
||||
server.version=1.2.154
|
||||
|
||||
Loading…
Reference in New Issue
Block a user