update added jwt tokens
This commit is contained in:
1 parent
a5e28c5533
commit
b0064a9b81
25 files changed
+2004
-31
No files matched your search
Generated
+883
-1
File diff suppressed because it is too large.
Load diff
@@ -13,3 +13,7 @@ serde_json = "1.0"
|
||||
clap = { version = "4.4", features = ["derive"] }
|
||||
anyhow = "1.0"
|
||||
moka = { version = "0.12", features = ["sync", "future"] }
|
||||
jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] }
|
||||
chrono = "0.4.42"
|
||||
tower-http = { version = "0.5", features = ["fs"] }
|
||||
uuid = { version = "1.7", features = ["v4", "fast-rng"] }
|
||||
+5
-3
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"host": "0.0.0.0",
|
||||
"port": 8888,
|
||||
"log_level": "debug"
|
||||
"host": "0.0.0.0",
|
||||
"port": 8888,
|
||||
"log_level": "debug",
|
||||
"jwt_secret": "secret",
|
||||
"web": "./web"
|
||||
}
|
||||
+22
-1
@@ -7,6 +7,8 @@ struct FileConfig {
|
||||
host: Option<String>,
|
||||
port: Option<u16>,
|
||||
log_level: Option<String>,
|
||||
jwt_secret: Option<String>,
|
||||
web: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -23,6 +25,12 @@ struct Cli {
|
||||
|
||||
#[arg(short, long)]
|
||||
log_level: Option<String>,
|
||||
|
||||
#[arg(short, long)]
|
||||
jwt_secret: Option<String>,
|
||||
|
||||
#[arg(short, long)]
|
||||
web: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -30,6 +38,8 @@ pub struct Config {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub log_level: String,
|
||||
pub jwt_secret: String,
|
||||
pub web: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -43,6 +53,8 @@ impl Config {
|
||||
host: None,
|
||||
port: None,
|
||||
log_level: None,
|
||||
jwt_secret: None,
|
||||
web: None,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -55,11 +67,20 @@ impl Config {
|
||||
.log_level
|
||||
.or(file_config.log_level)
|
||||
.unwrap_or_else(|| "info".to_string());
|
||||
|
||||
let jwt_secret: String = cli
|
||||
.jwt_secret
|
||||
.or(file_config.jwt_secret)
|
||||
.unwrap_or_else(|| "secret".to_string());
|
||||
let web: String = cli
|
||||
.web
|
||||
.or(file_config.web)
|
||||
.unwrap_or_else(|| "./web".to_string());
|
||||
Ok(Config {
|
||||
host,
|
||||
port,
|
||||
log_level,
|
||||
jwt_secret,
|
||||
web,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+207
-22
@@ -3,14 +3,23 @@ use crate::model;
|
||||
use crate::state;
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use chrono::Utc;
|
||||
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||
use std::time::Instant;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn get_all_users_handler(
|
||||
State(state): State<state::AppState>,
|
||||
) -> Json<Vec<model::GetUserResponse>> {
|
||||
Json(payload): Json<model::GetAllUsersRequest>,
|
||||
) -> Result<Json<Vec<model::GetUserResponse>>, Json<model::ErrorResponse>> {
|
||||
if !(verify_jwt_token(&state, payload.token)) {
|
||||
return Err(Json(model::ErrorResponse {
|
||||
error: "Invalid token".to_string(),
|
||||
}));
|
||||
}
|
||||
let key = "all_users".to_string();
|
||||
if let Some(cached_data) = state.cache.get(&key) {
|
||||
return Json(cached_data.0);
|
||||
return Ok(Json(cached_data.0));
|
||||
}
|
||||
let storage_guard = state.storage.lock().await;
|
||||
let users: Vec<model::User> = storage_guard.values().cloned().collect();
|
||||
@@ -27,16 +36,22 @@ pub async fn get_all_users_handler(
|
||||
state
|
||||
.cache
|
||||
.insert(key, (mapped_users.clone(), Instant::now()));
|
||||
Json(mapped_users)
|
||||
Ok(Json(mapped_users))
|
||||
}
|
||||
|
||||
pub async fn get_user_by_id_handler(
|
||||
State(state): State<state::AppState>,
|
||||
Json(payload): Json<model::GetUserByIdRequest>,
|
||||
) -> Result<Json<model::GetUserResponse>, Json<model::ErrorResponse>> {
|
||||
if !verify_jwt_token(&state, payload.token.clone()) {
|
||||
return Err(Json(model::ErrorResponse {
|
||||
error: "Invalid token".to_string(),
|
||||
}));
|
||||
}
|
||||
let claims = get_jwt_claims(&state, payload.token.clone()).map_err(|err| err)?;
|
||||
let storage_guard = state.storage.lock().await;
|
||||
storage_guard
|
||||
.get(&payload.id)
|
||||
.get(&claims.user_id)
|
||||
.cloned()
|
||||
.map(|user| {
|
||||
Json(model::GetUserResponse {
|
||||
@@ -58,32 +73,48 @@ pub async fn get_user_by_username_handler(
|
||||
State(state): State<state::AppState>,
|
||||
Json(payload): Json<model::GetUserByUsernameRequest>,
|
||||
) -> Result<Json<model::GetUserResponse>, Json<model::ErrorResponse>> {
|
||||
if !(verify_jwt_token(&state, payload.token)) {
|
||||
return Err(Json(model::ErrorResponse {
|
||||
error: "Invalid token".to_string(),
|
||||
}));
|
||||
}
|
||||
let storage_guard = state.storage.lock().await;
|
||||
storage_guard
|
||||
.get(&payload.username)
|
||||
.cloned()
|
||||
.map(|user| {
|
||||
Json(model::GetUserResponse {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
group: user.group,
|
||||
is_superuser: user.is_superuser,
|
||||
})
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
Json(model::ErrorResponse {
|
||||
error: "User not found".to_string(),
|
||||
})
|
||||
})
|
||||
let users: Vec<model::User> = storage_guard.values().cloned().collect();
|
||||
let user = users
|
||||
.into_iter()
|
||||
.find(|user| user.username == payload.username);
|
||||
if user.is_none() {
|
||||
return Err(Json(model::ErrorResponse {
|
||||
error: "User not found".to_string(),
|
||||
}));
|
||||
}
|
||||
let user = user.unwrap();
|
||||
Ok(Json(model::GetUserResponse {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
group: user.group,
|
||||
is_superuser: user.is_superuser,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn add_user_handler(
|
||||
State(state): State<state::AppState>,
|
||||
Json(payload): Json<model::AddUserRequest>,
|
||||
) -> Result<Json<model::GetUserResponse>, Json<model::ErrorResponse>> {
|
||||
if !verify_jwt_token(&state, payload.token.clone()) {
|
||||
return Err(Json(model::ErrorResponse {
|
||||
error: "Invalid token".to_string(),
|
||||
}));
|
||||
}
|
||||
let claims = get_jwt_claims(&state, payload.token).map_err(|err| err)?;
|
||||
if !claims.is_superuser {
|
||||
return Err(Json(model::ErrorResponse {
|
||||
error: "Permission denied".to_string(),
|
||||
}));
|
||||
}
|
||||
let user = model::User {
|
||||
id: payload.id.clone(),
|
||||
id: Uuid::new_v4().to_string(),
|
||||
username: payload.username.clone(),
|
||||
email: payload.email.clone(),
|
||||
password: payload.password.clone(),
|
||||
@@ -116,7 +147,18 @@ pub async fn delete_user_handler(
|
||||
State(state): State<state::AppState>,
|
||||
Json(payload): Json<model::DeleteUserRequest>,
|
||||
) -> Result<Json<model::DeleteUserResponse>, Json<model::ErrorResponse>> {
|
||||
if !verify_jwt_token(&state, payload.token.clone()) {
|
||||
return Err(Json(model::ErrorResponse {
|
||||
error: "Invalid token".to_string(),
|
||||
}));
|
||||
}
|
||||
let claims = get_jwt_claims(&state, payload.token.clone()).map_err(|error| error)?;
|
||||
let mut storage_guard = state.storage.lock().await;
|
||||
if claims.user_id != payload.id && !claims.is_superuser {
|
||||
return Err(Json(model::ErrorResponse {
|
||||
error: "Unauthorized".to_string(),
|
||||
}));
|
||||
}
|
||||
if storage_guard.remove(&payload.id).is_none() {
|
||||
return Err(Json(model::ErrorResponse {
|
||||
error: "User not found".to_string(),
|
||||
@@ -135,6 +177,17 @@ pub async fn update_user_handler(
|
||||
State(state): State<state::AppState>,
|
||||
Json(payload): Json<model::UpdateUserRequest>,
|
||||
) -> Result<Json<model::GetUserResponse>, Json<model::ErrorResponse>> {
|
||||
if !verify_jwt_token(&state, payload.token.clone()) {
|
||||
return Err(Json(model::ErrorResponse {
|
||||
error: "Unauthorized".to_string(),
|
||||
}));
|
||||
}
|
||||
let claims = get_jwt_claims(&state, payload.token.clone()).map_err(|e| e)?;
|
||||
if claims.user_id != payload.id && !claims.is_superuser {
|
||||
return Err(Json(model::ErrorResponse {
|
||||
error: "Unauthorized".to_string(),
|
||||
}));
|
||||
}
|
||||
let mut storage_guard = state.storage.lock().await;
|
||||
if !storage_guard.contains_key(&payload.id) {
|
||||
return Err(Json(model::ErrorResponse {
|
||||
@@ -168,3 +221,135 @@ pub async fn update_user_handler(
|
||||
is_superuser: payload.is_superuser,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_token_handler(
|
||||
State(state): State<state::AppState>,
|
||||
Json(payload): Json<model::GetTokenRequest>,
|
||||
) -> Result<Json<model::GetTokenResponse>, Json<model::ErrorResponse>> {
|
||||
let storage_guard = state.storage.lock().await;
|
||||
let users: Vec<model::User> = storage_guard.values().cloned().collect();
|
||||
let user = users
|
||||
.iter()
|
||||
.find(|user| user.username == payload.username && user.password == payload.password);
|
||||
if user.is_none() {
|
||||
return Err(Json(model::ErrorResponse {
|
||||
error: "Invalid username or password".to_string(),
|
||||
}));
|
||||
}
|
||||
let now = Utc::now();
|
||||
let expiration = (now + chrono::Duration::hours(24)).timestamp() as usize;
|
||||
let claims = model::JwtClaims {
|
||||
sub: "1234".to_owned(),
|
||||
exp: expiration,
|
||||
iat: now.timestamp() as usize,
|
||||
user_id: user.unwrap().id.clone(),
|
||||
username: user.unwrap().username.clone(),
|
||||
email: user.unwrap().email.clone(),
|
||||
group: user.unwrap().group.clone(),
|
||||
is_superuser: user.unwrap().is_superuser,
|
||||
};
|
||||
let token = encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(state.config.jwt_secret.as_ref()),
|
||||
)
|
||||
.map_err(|err| {
|
||||
Json(model::ErrorResponse {
|
||||
error: format!("Failed to encode JWT token: {}", err),
|
||||
})
|
||||
})?;
|
||||
Ok(Json(model::GetTokenResponse { token }))
|
||||
}
|
||||
|
||||
pub async fn verify_token_handler(
|
||||
State(state): State<state::AppState>,
|
||||
Json(payload): Json<model::VerifyTokenRequest>,
|
||||
) -> Result<Json<model::VerifyTokenResponse>, Json<model::ErrorResponse>> {
|
||||
let token = payload.token;
|
||||
if verify_jwt_token(&state, token) {
|
||||
Ok(Json(model::VerifyTokenResponse { success: true }))
|
||||
} else {
|
||||
Err(Json(model::ErrorResponse {
|
||||
error: "Invalid token".to_string(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_jwt_token(state: &state::AppState, token: String) -> bool {
|
||||
match decode::<model::JwtClaims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(state.config.jwt_secret.as_ref()),
|
||||
&Validation::new(jsonwebtoken::Algorithm::HS256),
|
||||
) {
|
||||
Ok(_) => true,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_jwt_claims(
|
||||
state: &state::AppState,
|
||||
token: String,
|
||||
) -> Result<model::JwtClaims, Json<model::ErrorResponse>> {
|
||||
match decode::<model::JwtClaims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(state.config.jwt_secret.as_ref()),
|
||||
&Validation::new(jsonwebtoken::Algorithm::HS256),
|
||||
) {
|
||||
Ok(claims) => Ok(claims.claims),
|
||||
Err(err) => Err(Json(model::ErrorResponse {
|
||||
error: format!("Failed to decode JWT token: {}", err),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_user_password_handler(
|
||||
State(state): State<state::AppState>,
|
||||
Json(payload): Json<model::UpdateUserPasswordRequest>,
|
||||
) -> Result<Json<model::UpdateUserPasswordResponse>, Json<model::ErrorResponse>> {
|
||||
if !verify_jwt_token(&state, payload.token.clone()) {
|
||||
return Err(Json(model::ErrorResponse {
|
||||
error: "Unauthorized".to_string(),
|
||||
}));
|
||||
}
|
||||
let claims = get_jwt_claims(&state, payload.token.clone()).map_err(|err| err)?;
|
||||
if payload.old_password.is_none() && !claims.is_superuser {
|
||||
return Err(Json(model::ErrorResponse {
|
||||
error: "Unauthorized".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
let mut storage_guard = state.storage.lock().await;
|
||||
let user = storage_guard.get(&payload.id).cloned();
|
||||
if user.is_none() {
|
||||
return Err(Json(model::ErrorResponse {
|
||||
error: "User not found".to_string(),
|
||||
}));
|
||||
}
|
||||
let user = user.unwrap();
|
||||
|
||||
if !payload.old_password.is_none() {
|
||||
if payload.old_password.unwrap() != user.password {
|
||||
return Err(Json(model::ErrorResponse {
|
||||
error: "Incorrect old password".to_string(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
storage_guard.insert(
|
||||
payload.id.clone(),
|
||||
model::User {
|
||||
id: payload.id.clone(),
|
||||
username: user.username.clone(),
|
||||
email: user.email.clone(),
|
||||
password: payload.new_password.clone(),
|
||||
group: user.group.clone(),
|
||||
is_superuser: user.is_superuser,
|
||||
},
|
||||
);
|
||||
let storage_clone = storage_guard.clone(); // write to file in background
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = db::write_users_to_file(&storage_clone).await {
|
||||
eprintln!("Failed to write users to file: {}", e);
|
||||
}
|
||||
});
|
||||
Ok(Json(model::UpdateUserPasswordResponse { success: true }))
|
||||
}
|
||||
+57
-3
@@ -1,6 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct User {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
@@ -12,17 +12,18 @@ pub struct User {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GetUserByIdRequest {
|
||||
pub id: String,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GetUserByUsernameRequest {
|
||||
pub token: String,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct AddUserRequest {
|
||||
pub id: String,
|
||||
pub token: String,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
@@ -41,6 +42,7 @@ pub struct GetUserResponse {
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct DeleteUserRequest {
|
||||
pub token: String,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
@@ -51,6 +53,7 @@ pub struct DeleteUserResponse {
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct UpdateUserRequest {
|
||||
pub token: String,
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
@@ -62,3 +65,54 @@ pub struct UpdateUserRequest {
|
||||
pub struct ErrorResponse {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct JwtClaims {
|
||||
pub sub: String,
|
||||
pub exp: usize,
|
||||
pub iat: usize,
|
||||
pub user_id: String,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub group: String,
|
||||
pub is_superuser: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct GetTokenRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct GetTokenResponse {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct VerifyTokenRequest {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct VerifyTokenResponse {
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct GetAllUsersRequest {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpdateUserPasswordRequest {
|
||||
pub token: String,
|
||||
pub id: String,
|
||||
pub new_password: String,
|
||||
pub old_password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpdateUserPasswordResponse {
|
||||
pub success: bool,
|
||||
}
|
||||
@@ -2,6 +2,7 @@ use crate::handlers;
|
||||
use crate::state;
|
||||
use axum::{Router, routing::delete, routing::post, routing::put};
|
||||
use std::net::SocketAddr;
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
pub async fn start_server() -> anyhow::Result<()> {
|
||||
let state = state::AppState::new().await?;
|
||||
@@ -30,6 +31,13 @@ pub fn create_app(state: state::AppState) -> Router {
|
||||
.route("/api/users/add", post(handlers::add_user_handler))
|
||||
.route("/api/users", delete(handlers::delete_user_handler))
|
||||
.route("/api/users", put(handlers::update_user_handler))
|
||||
.route("/api/token", post(handlers::get_token_handler))
|
||||
.route("/api/token/verify", post(handlers::verify_token_handler))
|
||||
.route(
|
||||
"/api/users/password",
|
||||
post(handlers::update_user_password_handler),
|
||||
)
|
||||
.nest_service("/", ServeDir::new(state.config.web.clone()))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ impl AppState {
|
||||
let user_storage = db::init_user_storage().await;
|
||||
let cache = Cache::builder()
|
||||
.max_capacity(10_000) // максимальное количество записей
|
||||
.time_to_live(Duration::from_secs(300)) // TTL 5 минут
|
||||
.time_to_live(Duration::from_secs(2)) // TTL 5 минут
|
||||
.build();
|
||||
let state = AppState {
|
||||
storage: user_storage,
|
||||
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
@charset "UTF-8";/*!
|
||||
* Bootstrap v5.3.5 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2025 The Bootstrap Authors
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||
*/:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherLine truncated
|
||||
/*# sourceMappingURL=bootstrap.min.css.map */
|
||||
@@ -0,0 +1,72 @@
|
||||
.form {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
transition: 0.3s;
|
||||
background-color: black;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.form.confirmform {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.form.active {
|
||||
height: 100%;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.formbody {
|
||||
padding: 10px;
|
||||
background-color: gray;
|
||||
backdrop-filter: blur(5px);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.loginformheader {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loginforminputbody {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.loginforminput {
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.loginforminputbodyflex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.loginforminputbodyflex > button {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.loginformbutton {
|
||||
padding: 10px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
background-color: rgb(59, 59, 197);
|
||||
color: white;
|
||||
transition: 0.3s;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.loginformbutton:hover {
|
||||
background-color: rgb(151, 151, 241);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
* {
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.notificationsection {
|
||||
width: max-content;
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
z-index: 16;
|
||||
}
|
||||
|
||||
.notification {
|
||||
width: max-content;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
border-radius: 10px;
|
||||
top: 0;
|
||||
right: 0;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
margin: 10px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.notificationtextbox {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.notificationtext {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.notification.active.error {
|
||||
pointer-events: all;
|
||||
height: 10%;
|
||||
background-color: rgb(233, 70, 70);
|
||||
}
|
||||
|
||||
.notification.active.success {
|
||||
pointer-events: all;
|
||||
height: 10%;
|
||||
background-color: rgb(76, 209, 76);
|
||||
}
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="stylesheet" href="/css/loginform.css" />
|
||||
<link rel="stylesheet" href="/css/message.css" />
|
||||
<link rel="stylesheet" href="/css/bootstrap.min.css" />
|
||||
<title>admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="navbar navbar-expand-lg bg-body-tertiary gap-3 p-2">
|
||||
<button class="btn btn-danger" id="exitbutton">Exit</button>
|
||||
<button class="btn btn-warning" id="adduserbutton">Adduser</button>
|
||||
<h1 class="usernameheader"></h1>
|
||||
<div class="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="themecheckbox"
|
||||
class="form-check-input"
|
||||
/>
|
||||
<label for="themecheckbox" class="form-check-label"
|
||||
>Dark theme</label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form loginform" id="loginform">
|
||||
<form class="formbody loginformbody" id="loginformbody">
|
||||
<h1 class="loginformheader">Login</h1>
|
||||
<div class="form-floating mb-3">
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
id="loginforminputusername"
|
||||
class="form-control"
|
||||
placeholder="John"
|
||||
/>
|
||||
<label for="loginforminputusername">Username</label>
|
||||
</div>
|
||||
<div class="form-floating mb-3">
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
id="loginforminputpassword"
|
||||
class="form-control"
|
||||
placeholder="***"
|
||||
/>
|
||||
<label for="loginforminputpassword">Password</label>
|
||||
</div>
|
||||
<div class="d-grid gap-2">
|
||||
<button class="btn btn-primary">login</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="form adduserform">
|
||||
<form class="formbody adduserformbody">
|
||||
<h1 class="loginformheader">Add User</h1>
|
||||
<div class="form-floating mb-3">
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
id="adduserformusername"
|
||||
class="form-control"
|
||||
/>
|
||||
<label for="adduserformusername">Username</label>
|
||||
</div>
|
||||
<div class="form-floating mb-3">
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
id="adduserformemail"
|
||||
class="form-control"
|
||||
/>
|
||||
<label for="adduserformemail">Email</label>
|
||||
</div>
|
||||
<div class="form-floating mb-3">
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
id="adduserformpassword"
|
||||
class="form-control"
|
||||
/>
|
||||
<label for="adduserformpassword">Password</label>
|
||||
</div>
|
||||
<div class="form-floating mb-3">
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
id="adduserformgroup"
|
||||
class="form-control"
|
||||
/>
|
||||
<label for="adduserformgroup">Group</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="adduserformissuperuser"
|
||||
class="form-check-input"
|
||||
/>
|
||||
<label for="adduserformissuperuser" class="form-check-label"
|
||||
>Is Superuser</label
|
||||
>
|
||||
</div>
|
||||
<div class="d-grid gap-2">
|
||||
<button class="btn btn-primary">Add</button>
|
||||
<button id="canceladduser" class="btn btn-primary">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="form changeuserform">
|
||||
<form class="formbody changeuserformbody">
|
||||
<h1 class="loginformheader">Change User Data</h1>
|
||||
<div class="form-floating mb-3">
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
id="changeuserformusername"
|
||||
class="form-control"
|
||||
placeholder="Username"
|
||||
/>
|
||||
<label for="changeuserformusername">Username</label>
|
||||
</div>
|
||||
<div class="form-floating mb-3">
|
||||
<input
|
||||
type="text"
|
||||
id="changeuserformemail"
|
||||
class="form-control"
|
||||
placeholder="***"
|
||||
/>
|
||||
<label for="changeuserformemail">email</label>
|
||||
</div>
|
||||
<div class="form-floating mb-3">
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
id="changeuserformgroup"
|
||||
class="form-control"
|
||||
/>
|
||||
<label for="changeuserformgroup">Group</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="changeuserformissuperuser"
|
||||
class="form-check-input"
|
||||
/>
|
||||
<label
|
||||
for="changeuserformissuperuser"
|
||||
class="form-check-label"
|
||||
>Is Superuser</label
|
||||
>
|
||||
</div>
|
||||
<div class="d-grid gap-2">
|
||||
<button class="btn btn-primary">Change</button>
|
||||
<button id="cancelchangeuser" class="btn btn-primary">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="form confirmform">
|
||||
<div class="formbody confirmformbody">
|
||||
<h1>Are you sure?</h1>
|
||||
<div class="d-grid gap-2">
|
||||
<button id="yes" class="btn btn-primary">Confirm</button>
|
||||
<button id="no" class="btn btn-primary">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form changepasswordform">
|
||||
<form class="formbody changepasswordformbody">
|
||||
<h1 class="loginformheader">Change Password</h1>
|
||||
<div class="form-floating mb-3">
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
id="changepasswordnewpassword"
|
||||
class="form-control"
|
||||
placeholder="***"
|
||||
/>
|
||||
<label for="changepasswordnewpassword">New password</label>
|
||||
</div>
|
||||
<div class="d-grid gap-2">
|
||||
<button class="btn btn-primary">Change</button>
|
||||
<button class="btn btn-primary" id="cancelchangepassword">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="notificationsection"></div>
|
||||
|
||||
<div class="table">
|
||||
<table class="table">
|
||||
<thead class="alluserstablehead">
|
||||
<tr>
|
||||
<th>id</th>
|
||||
<th>username</th>
|
||||
<th>email</th>
|
||||
<th>group</th>
|
||||
<th>is_superuser</th>
|
||||
<th>delete</th>
|
||||
<th>change password</th>
|
||||
<th>change user data</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="alluserstablebody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<script src="/js/crypto-js.min.js"></script>
|
||||
<script src="/js/security.js"></script>
|
||||
<script src="/js/message.js"></script>
|
||||
<script src="/js/routes.js"></script>
|
||||
<script src="/js/window.js"></script>
|
||||
<script src="/js/theme.js"></script>
|
||||
<script src="/js/login.js"></script>
|
||||
<script src="/js/get_all_users.js"></script>
|
||||
<script src="/js/add_user.js"></script>
|
||||
<script src="/js/del_user.js"></script>
|
||||
<script src="/js/change_password.js"></script>
|
||||
<script src="/js/change_user.js"></script>
|
||||
<script src="/js/bootstrap.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,52 @@
|
||||
const adduserbutton = document.querySelector("#adduserbutton");
|
||||
const adduserform = document.querySelector(".adduserform");
|
||||
const canceladduserbutton = document.querySelector("#canceladduser");
|
||||
const adduserformbody = document.querySelector(".adduserformbody");
|
||||
const usernameinput = document.querySelector("#adduserformusername");
|
||||
const passwordinput = document.querySelector("#adduserformpassword");
|
||||
const groupinput = document.querySelector("#adduserformgroup");
|
||||
const is_superuserinput = document.querySelector("#adduserformissuperuser");
|
||||
const emailinput = document.querySelector("#adduserformemail");
|
||||
|
||||
adduserbutton.addEventListener("click", () => {
|
||||
adduserform.classList.add("active");
|
||||
});
|
||||
|
||||
canceladduserbutton.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
adduserform.classList.remove("active");
|
||||
});
|
||||
|
||||
adduserformbody.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const hashedpassword = await hashPassword(passwordinput.value);
|
||||
const new_user = {
|
||||
username: usernameinput.value,
|
||||
email: emailinput.value,
|
||||
password: hashedpassword,
|
||||
group: groupinput.value,
|
||||
is_superuser: is_superuserinput.checked,
|
||||
};
|
||||
const status = await fetch(routes.adduser(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: `{"token":"${localStorage.getItem("token")}","username":"${new_user.username}","email":"${new_user.email}","password":"${new_user.password}","group":"${new_user.group}","is_superuser":${new_user.is_superuser}}`,
|
||||
})
|
||||
.then((data) => data.json())
|
||||
.then((jsondata) => jsondata)
|
||||
.catch((error) => {
|
||||
notification(`Ошибка на сервере: ${error}`, "error");
|
||||
});
|
||||
if (!status.error) {
|
||||
adduserform.classList.remove("active");
|
||||
await get_all_users();
|
||||
notification("Пользователь успешно добавлен", "success");
|
||||
usernameinput.value = "";
|
||||
passwordinput.value = "";
|
||||
groupinput.value = "";
|
||||
} else {
|
||||
notification(status.error, "error");
|
||||
}
|
||||
});
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/*!
|
||||
* Bootstrap v5.3.5 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2025 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||
*/
|
||||
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e(require("@popperjs/core")):"function"==typeof define&&define.amd?define(["@popperjs/core"],e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e(t.Popper)}(this,(function(t){"use strict";function e(t){const e=Object.create(null,{[Symbol.toStringTag]:{value:"Module"}});if(t)for(const i in t)if("default"!==i){const s=Object.getOwnPropertyDescriptor(t,i);Object.defineProperty(e,i,s.get?s:{enumerable:!0,get:()=>t[i]})}return e.default=t,Object.freeze(e)}const i=e(t),s=new Map,n={set(t,e,i){s.has(t)||s.set(t,new Map);const n=s.get(t);n.has(e)||0===n.size?n.set(e,i):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(n.keys())[0]}.`)},get:(t,e)=>s.has(t)&&s.get(t).get(e)||null,remove(t,e){if(!s.has(t))return;const i=s.get(t);i.delete(e),0===i.size&&s.delete(t)}},o="transitionend",r=t=>(t&&window.CSS&&window.CSS.escape&&(t=t.replace(/#([^\s"#']+)/g,((t,e)=>`#${CSS.escape(e)}`))),t),a=t=>{t.dispatchEvent(new Event(o))},l=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),c=t=>l(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(r(t)):null,h=t=>{if(!l(t)||0===t.getClientRects().length)return!1;const e="visible"===getComputedStyle(t).getPropertyValue("visibility"),i=t.closest("details:not([open])");if(!i)return e;if(i!==t){const e=t.closest("summary");if(e&&e.parentNode!==i)return!1;if(null===e)return!1}return e},d=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),u=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?u(t.parentNode):null},_=()=>{},g=t=>{t.offsetHeight},f=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,m=[],p=()=>"rtl"===document.documentElement.dir,b=t=>{var e;e=()=>{const e=f();if(e){const i=t.NAME,s=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=s,t.jQueryInterface)}},"loading"===document.readyState?(m.length||document.addEventListener("DOMContentLoaded",(()=>{for(const t of m)t()})),m.push(e)):e()},v=(t,e=[],i=t)=>"function"==typeof t?t.call(...e):i,y=(t,e,i=!0)=>{if(!i)return void v(t);const s=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const s=Number.parseFloat(e),n=Number.parseFloat(i);return s||n?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(e)+5;let n=!1;const r=({target:i})=>{i===e&&(n=!0,e.removeEventListener(o,r),v(t))};e.addEventListener(o,r),setTimeout((()=>{n||a(e)}),s)},w=(t,e,i,s)=>{const n=t.length;let o=t.indexOf(e);return-1===o?!i&&s?t[n-1]:t[0]:(o+=i?1:-1,s&&(o=(o+n)%n),t[Math.max(0,Math.min(o,n-1))])},A=/[^.]*(?=\..*)\.|.*/,E=/\..*/,C=/::\d+$/,T={};let k=1;const $={mouseenter:"mouseover",mouseleave:"mouseout"},S=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function L(t,e){return e&&`${e}::${k++}`||t.uidEvent||k++}function O(t){const e=L(t);return t.uidEvent=e,T[e]=T[e]||{},T[e]}function I(t,e,i=null){return Object.values(t).find((t=>t.callable===e&&t.delegationSelector===i))}function D(t,e,i){const s="string"==typeof e,n=s?i:e||i;let o=M(t);return S.has(o)||(o=t),[s,n,o]}function N(t,e,i,s,n){if("string"!=typeof e||!t)return;let[o,r,a]=D(e,i,s);if(e in $){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};r=t(r)}const l=O(t),c=l[a]||(l[a]={}),h=I(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&n);const d=L(r,e.replace(A,"")),u=o?function(t,e,i){return function s(n){const o=t.querySelectorAll(e);for(let{target:r}=n;r&&r!==this;r=r.parentNode)for(const a of o)if(a===r)return F(n,{delegateTarget:r}),s.oneOff&&j.off(t,n.type,e,i),i.apply(r,[n])}}(t,i,r):function(t,e){return function i(s){return F(s,{delegateTarget:t}),i.oneOff&&j.off(t,s.type,e),e.apply(t,[s])}}(t,r);u.delegationSelector=o?i:null,u.callable=r,u.oneOff=n,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function P(t,e,i,s,n){const o=I(e[i],s,n);o&&(t.removeEventListener(i,o,Boolean(n)),delete e[i][o.uidEvent])}function x(t,e,i,s){const n=e[i]||{};for(const[o,r]of Object.entries(n))o.includes(Line truncated
|
||||
//# sourceMappingURL=bootstrap.min.js.map
|
||||
@@ -0,0 +1,44 @@
|
||||
const changepasswordform = document.querySelector(".changepasswordform");
|
||||
const cancelchangepassword = document.querySelector("#cancelchangepassword");
|
||||
const changepasswordformbody = document.querySelector(
|
||||
".changepasswordformbody",
|
||||
);
|
||||
const changepasswordnewpassword = document.querySelector(
|
||||
"#changepasswordnewpassword",
|
||||
);
|
||||
|
||||
let changepassuserid = "";
|
||||
|
||||
const change_password = (userid) => {
|
||||
changepasswordform.classList.add("active");
|
||||
changepassuserid = userid;
|
||||
};
|
||||
|
||||
cancelchangepassword.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
changepasswordform.classList.remove("active");
|
||||
});
|
||||
|
||||
changepasswordformbody.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const hashedpassword = await hashPassword(changepasswordnewpassword.value);
|
||||
const status = await fetch(routes.change_password_url(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: `{"token":"${localStorage.getItem("token")}","id":"${changepassuserid}","new_password":"${hashedpassword}"}`,
|
||||
})
|
||||
.then((data) => data.json())
|
||||
.then((jsondata) => jsondata)
|
||||
.catch((error) => {
|
||||
notification(`Ошибка на сервере: ${error}`, "error");
|
||||
});
|
||||
if (!status.error) {
|
||||
notification("Пароль успешно изменен", "success");
|
||||
changepasswordform.classList.remove("active");
|
||||
changepasswordnewpassword.value = "";
|
||||
} else {
|
||||
notification(status.error, "error");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
const changeuserform = document.querySelector(".changeuserform");
|
||||
const changeuserformbody = document.querySelector(".changeuserformbody");
|
||||
const changeuserformusername = document.querySelector(
|
||||
"#changeuserformusername",
|
||||
);
|
||||
const changeuserformemail = document.querySelector("#changeuserformemail");
|
||||
const changeuserformgroup = document.querySelector("#changeuserformgroup");
|
||||
const changeuserformissuperuser = document.querySelector(
|
||||
"#changeuserformissuperuser",
|
||||
);
|
||||
const cancelchangeuser = document.querySelector("#cancelchangeuser");
|
||||
|
||||
let changedatauserid = "";
|
||||
let userdata = "";
|
||||
|
||||
const change_user_data = async (userid) => {
|
||||
changeuserform.classList.add("active");
|
||||
changedatauserid = userid;
|
||||
const all_users = await fetch(routes.all_users(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: `{"token":"${localStorage.getItem("token")}"}`,
|
||||
})
|
||||
.then((data) => data.json())
|
||||
.then((jsondata) => jsondata)
|
||||
.catch((error) => {
|
||||
notification(`Ошибка на сервере: ${error}`, "error");
|
||||
});
|
||||
userdata = all_users.find((usr) => usr.id === userid);
|
||||
changeuserformusername.value = userdata.username;
|
||||
changeuserformgroup.value = userdata.group;
|
||||
changeuserformissuperuser.checked = userdata.is_superuser === "1";
|
||||
changeuserformemail.value = userdata.email;
|
||||
};
|
||||
|
||||
cancelchangeuser.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
changeuserform.classList.remove("active");
|
||||
});
|
||||
|
||||
changeuserformbody.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
let request = {
|
||||
token: localStorage.getItem("token"),
|
||||
id: changedatauserid,
|
||||
username: changeuserformusername.value,
|
||||
email: changeuserformemail.value,
|
||||
is_superuser: changeuserformissuperuser.checked,
|
||||
group: changeuserformgroup.value,
|
||||
};
|
||||
const status = await fetch(routes.change_user(), {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
.then((data) => data.json())
|
||||
.then((jsondata) => jsondata)
|
||||
.catch((error) => {
|
||||
notification(`Ошибка на сервере: ${error}`, "error");
|
||||
});
|
||||
if (!status.error) {
|
||||
notification("Данные пользователя успешно изменены", "success");
|
||||
changeuserform.classList.remove("active");
|
||||
await get_all_users();
|
||||
} else {
|
||||
notification(status.error, "error");
|
||||
}
|
||||
});
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
!function(t,e){"object"==typeof exports?module.exports=exports=e():"function"==typeof define&&define.amd?define([],e):t.CryptoJS=e()}(this,function(){var n,o,s,a,h,t,e,l,r,i,c,f,d,u,p,S,x,b,A,H,z,_,v,g,y,B,w,k,m,C,D,E,R,M,F,P,W,O,I,U=U||function(h){var i;if("undefined"!=typeof window&&window.crypto&&(i=window.crypto),"undefined"!=typeof self&&self.crypto&&(i=self.crypto),!(i=!(i=!(i="undefined"!=typeof globalThis&&globalThis.crypto?globalThis.crypto:i)&&"undefined"!=typeof window&&window.msCrypto?window.msCrypto:i)&&"undefined"!=typeof global&&global.crypto?global.crypto:i)&&"function"==typeof require)try{i=require("crypto")}catch(t){}var r=Object.create||function(t){return e.prototype=t,t=new e,e.prototype=null,t};function e(){}var t={},n=t.lib={},o=n.Base={extend:function(t){var e=r(this);return t&&e.mixIn(t),e.hasOwnProperty("init")&&this.init!==e.init||(e.init=function(){e.$super.init.apply(this,arguments)}),(e.init.prototype=e).$super=this,e},create:function(){var t=this.extend();return t.init.apply(t,arguments),t},init:function(){},mixIn:function(t){for(var e in t)t.hasOwnProperty(e)&&(this[e]=t[e]);t.hasOwnProperty("toString")&&(this.toString=t.toString)},clone:function(){return this.init.prototype.extend(this)}},l=n.WordArray=o.extend({init:function(t,e){t=this.words=t||[],this.sigBytes=null!=e?e:4*t.length},toString:function(t){return(t||c).stringify(this)},concat:function(t){var e=this.words,r=t.words,i=this.sigBytes,n=t.sigBytes;if(this.clamp(),i%4)for(var o=0;o<n;o++){var s=r[o>>>2]>>>24-o%4*8&255;e[i+o>>>2]|=s<<24-(i+o)%4*8}else for(var c=0;c<n;c+=4)e[i+c>>>2]=r[c>>>2];return this.sigBytes+=n,this},clamp:function(){var t=this.words,e=this.sigBytes;t[e>>>2]&=4294967295<<32-e%4*8,t.length=h.ceil(e/4)},clone:function(){var t=o.clone.call(this);return t.words=this.words.slice(0),t},random:function(t){for(var e=[],r=0;r<t;r+=4)e.push(function(){if(i){if("function"==typeof i.getRandomValues)try{return i.getRandomValues(new Uint32Array(1))[0]}catch(t){}if("function"==typeof i.randomBytes)try{return i.randomBytes(4).readInt32LE()}catch(t){}}throw new Error("Native crypto module could not be used to get secure random number.")}());return new l.init(e,t)}}),s=t.enc={},c=s.Hex={stringify:function(t){for(var e=t.words,r=t.sigBytes,i=[],n=0;n<r;n++){var o=e[n>>>2]>>>24-n%4*8&255;i.push((o>>>4).toString(16)),i.push((15&o).toString(16))}return i.join("")},parse:function(t){for(var e=t.length,r=[],i=0;i<e;i+=2)r[i>>>3]|=parseInt(t.substr(i,2),16)<<24-i%8*4;return new l.init(r,e/2)}},a=s.Latin1={stringify:function(t){for(var e=t.words,r=t.sigBytes,i=[],n=0;n<r;n++){var o=e[n>>>2]>>>24-n%4*8&255;i.push(String.fromCharCode(o))}return i.join("")},parse:function(t){for(var e=t.length,r=[],i=0;i<e;i++)r[i>>>2]|=(255&t.charCodeAt(i))<<24-i%4*8;return new l.init(r,e)}},f=s.Utf8={stringify:function(t){try{return decodeURIComponent(escape(a.stringify(t)))}catch(t){throw new Error("Malformed UTF-8 data")}},parse:function(t){return a.parse(unescape(encodeURIComponent(t)))}},d=n.BufferedBlockAlgorithm=o.extend({reset:function(){this._data=new l.init,this._nDataBytes=0},_append:function(t){"string"==typeof t&&(t=f.parse(t)),this._data.concat(t),this._nDataBytes+=t.sigBytes},_process:function(t){var e,r=this._data,i=r.words,n=r.sigBytes,o=this.blockSize,s=n/(4*o),c=(s=t?h.ceil(s):h.max((0|s)-this._minBufferSize,0))*o,n=h.min(4*c,n);if(c){for(var a=0;a<c;a+=o)this._doProcessBlock(i,a);e=i.splice(0,c),r.sigBytes-=n}return new l.init(e,n)},clone:function(){var t=o.clone.call(this);return t._data=this._data.clone(),t},_minBufferSize:0}),u=(n.Hasher=d.extend({cfg:o.extend(),init:function(t){this.cfg=this.cfg.extend(t),this.reset()},reset:function(){d.reset.call(this),this._doReset()},update:function(t){return this._append(t),this._process(),this},finalize:function(t){return t&&this._append(t),this._doFinalize()},blockSize:16,_createHelper:function(r){return function(t,e){return new r.init(e).finalize(t)}},_createHmacHelper:function(r){return function(t,e){return new u.HMAC.init(r,e).finalize(t)}}}),t.algo={});return t}(Math);function K(t,e,r){return t&e|~t&r}function X(t,e,r){return t&r|e&~r}function L(t,e){return t<<e|t>>>32-e}function j(t,e,r,i){var n,o=this._iv;o?(n=o.slice(0),this._iv=void 0):n=this._prevBlock,i.encryptBlock(n,0);for(var s=0;s<r;s++)t[e+s]^=n[s]}function T(t){var e,r,i;return 255==(t>>24&255)?(r=t>>8&255,i=255&t,255===(e=t>>16&255)?(e=0,255===r?(r=0,255===i?i=0:++i):++r):++e,t=0,t+=e<<16,t+=r<<8,t+=i):t+=1<<24,t}function N(){for(var t=this._X,e=this._C,r=0;r<8;r++)E[r]=e[r];e[0]=e[0]+1295307597+this._b|0,e[1]=e[1]+3545052371+(e[0]>>>0<E[0]>>>0?1:0)|0,e[2]=e[2]+886263092+(e[1]>>>0<E[1]>>>0?1:0)|0,e[3]=e[3]+1295307597+(e[2]>>>0<E[2]>>>0?1:0)|0,e[4]=e[4]+3545052371+(e[3]>>>0<E[3]>>>0?1:0)|0,e[5]=e[5]+886263092+(e[4]>>>0<E[4]>>>0?1:0)|0,e[6]=e[6]+1295307597+(e[5]>>>0<E[5]>>>0?1:0)|0,e[7]=e[7]+3545052371+(e[6]>>>0<E[6]>>>0?1:0)|0,this._b=e[7]>>>0<E[7]>>>0?1:0;for(r=0;r<8;r++){var i=t[r]+e[r],n=65535&i,o=i>>>16;R[Line truncated
|
||||
@@ -0,0 +1,47 @@
|
||||
const userstablebody = document.querySelector("#alluserstablebody");
|
||||
const confirmform = document.querySelector(".confirmform");
|
||||
const confirmformcancel = document.querySelector("#no");
|
||||
const confirmformok = document.querySelector("#yes");
|
||||
|
||||
let useridtodelete = "";
|
||||
|
||||
userstablebody.addEventListener("click", (e) => {
|
||||
if (e.target.classList.contains("deleteuser")) {
|
||||
useridtodelete = e.target.id;
|
||||
confirmform.classList.add("active");
|
||||
}
|
||||
if (e.target.classList.contains("changepassword")) {
|
||||
change_password(e.target.id);
|
||||
}
|
||||
if (e.target.classList.contains("changeuser")) {
|
||||
change_user_data(e.target.id);
|
||||
}
|
||||
});
|
||||
|
||||
confirmformcancel.addEventListener("click", () => {
|
||||
useridtodelete = "";
|
||||
confirmform.classList.remove("active");
|
||||
});
|
||||
|
||||
confirmformok.addEventListener("click", async () => {
|
||||
const status = await fetch(routes.deluser(), {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: `{"token":"${localStorage.getItem("token")}","id":"${useridtodelete}"}`,
|
||||
})
|
||||
.then((data) => data.json())
|
||||
.then((jsondata) => jsondata)
|
||||
.catch((error) => {
|
||||
notification(`Ошибка на сервере: ${error}`, "error");
|
||||
});
|
||||
if (status.success) {
|
||||
confirmform.classList.remove("active");
|
||||
await get_all_users();
|
||||
notification("Пользователь успешно удален", "success");
|
||||
} else {
|
||||
notification("Can't delete user", "error");
|
||||
confirmform.classList.remove("active");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
const get_all_users = async () => {
|
||||
const response = await fetch(routes.all_users(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: `{"token":"${localStorage.getItem("token")}"}`,
|
||||
})
|
||||
.then((data) => data.json())
|
||||
.then((jsondata) => jsondata)
|
||||
.catch((error) => {
|
||||
notification(`Ошибка на сервере: ${error}`, "error");
|
||||
});
|
||||
const alluserstablebody = document.querySelector("#alluserstablebody");
|
||||
alluserstablebody.innerHTML = "";
|
||||
for (const user of response) {
|
||||
alluserstablebody.innerHTML += `
|
||||
<tr>
|
||||
<th><p>${user.id}</p></th>
|
||||
<th>${user.username}</th>
|
||||
<th>${user.email}</th>
|
||||
<th>${user.group}</th>
|
||||
<th>${user.is_superuser}</th>
|
||||
<th><button class="deleteuser btn btn-danger" id="${user.id}">Delete</button></th>
|
||||
<th><button class="changepassword btn btn-warning" id="${user.id}">Change password</button></th>
|
||||
<th><button class="changeuser btn btn-warning" id="${user.id}">Change user data</button></th>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
const loginform = document.querySelector("#loginform");
|
||||
const loginformbody = document.querySelector("#loginformbody");
|
||||
const loginforminputusername = document.querySelector(
|
||||
"#loginforminputusername",
|
||||
);
|
||||
const loginforminputpassword = document.querySelector(
|
||||
"#loginforminputpassword",
|
||||
);
|
||||
const exitbutton = document.querySelector("#exitbutton");
|
||||
const usernameheader = document.querySelector(".usernameheader");
|
||||
|
||||
if (localStorage.getItem("token") === null) {
|
||||
loginform.classList.add("active");
|
||||
}
|
||||
|
||||
loginformbody.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
hashPassword(loginforminputpassword.value).then((hashedpassword) => {
|
||||
login(loginforminputusername.value, hashedpassword);
|
||||
});
|
||||
});
|
||||
|
||||
const login = async (username, password) => {
|
||||
const response = await fetch(routes.login(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: `{"username":"${username}","password":"${password}"}`,
|
||||
})
|
||||
.then((data) => data.json())
|
||||
.then((jsondata) => jsondata)
|
||||
.catch((error) => {
|
||||
if (error.message === "Unexpected end of JSON input") {
|
||||
notification(
|
||||
`Ошибка на сервере: Создайте файл users.json и запишите в него первого пользователя`,
|
||||
"error",
|
||||
);
|
||||
} else {
|
||||
notification(`Ошибка на сервере: ${error}`, "error");
|
||||
}
|
||||
});
|
||||
if (response.error) {
|
||||
loginforminputusername.classList.add("is-invalid");
|
||||
loginforminputpassword.classList.add("is-invalid");
|
||||
notification("Неправильный логин или пароль", "error");
|
||||
}
|
||||
if (response.token) {
|
||||
notification("Успешно", "success");
|
||||
loginform.classList.remove("active");
|
||||
localStorage.setItem("token", response.token);
|
||||
await get_user_data();
|
||||
loginforminputusername.value = "";
|
||||
loginforminputpassword.value = "";
|
||||
loginforminputusername.classList.remove("is-invalid");
|
||||
loginforminputpassword.classList.remove("is-invalid");
|
||||
}
|
||||
};
|
||||
|
||||
const get_user_data = async () => {
|
||||
const response = await fetch(routes.user(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: `{"token":"${localStorage.getItem("token")}"}`,
|
||||
})
|
||||
.then((data) => data.json())
|
||||
.then((jsondata) => jsondata)
|
||||
.catch((error) => {
|
||||
notification(`Ошибка на сервере: ${error}`, "error");
|
||||
});
|
||||
if (response.is_superuser) {
|
||||
await get_all_users();
|
||||
usernameheader.innerHTML = response.username;
|
||||
} else {
|
||||
document.querySelector("#alluserstablebody").innerHTML = "";
|
||||
loginform.classList.add("active");
|
||||
notification(
|
||||
"Вы не можете войти потому что не являетесь администратором",
|
||||
"error",
|
||||
);
|
||||
localStorage.clear();
|
||||
}
|
||||
if (response.id) {
|
||||
localStorage.setItem("userdata", JSON.stringify(response));
|
||||
}
|
||||
};
|
||||
|
||||
exitbutton.addEventListener("click", () => {
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("userdata");
|
||||
loginform.classList.add("active");
|
||||
notification("Вы вышли", "success");
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
const notificationsection = document.querySelector(".notificationsection");
|
||||
|
||||
const notification = (text, type) => {
|
||||
const notificationbox = document.createElement("div");
|
||||
const notificationtextbox = document.createElement("div");
|
||||
const notificationtext = document.createElement("h3");
|
||||
notificationtext.classList.add("notificationtext");
|
||||
notificationtextbox.append(notificationtext);
|
||||
notificationtextbox.classList.add("notificationtextbox");
|
||||
notificationtextbox.classList.add("card-header");
|
||||
notificationbox.append(notificationtextbox);
|
||||
notificationbox.classList.add("notification");
|
||||
notificationbox.addEventListener("click", () => {
|
||||
notificationbox.classList.remove("active");
|
||||
});
|
||||
notificationsection.append(notificationbox);
|
||||
if (type === "error") {
|
||||
notificationbox.classList.remove("success");
|
||||
notificationbox.classList.add("error");
|
||||
} else {
|
||||
notificationbox.classList.remove("error");
|
||||
notificationbox.classList.add("success");
|
||||
}
|
||||
notificationtext.innerHTML = text;
|
||||
setTimeout(() => {
|
||||
notificationbox.classList.add("active");
|
||||
}, 300);
|
||||
setTimeout(() => {
|
||||
notificationbox.classList.remove("active");
|
||||
setTimeout(() => {
|
||||
notificationsection.removeChild(notificationbox);
|
||||
}, 200);
|
||||
}, 3000);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
const host = window.location.origin
|
||||
const prefix = 'api'
|
||||
|
||||
const routes = {
|
||||
login: () => [host, prefix, 'token'].join('/'),
|
||||
user: () => [host, prefix, 'users', 'user'].join('/'),
|
||||
all_users: () => [host, prefix, 'users'].join('/'),
|
||||
access: () => [host, prefix, 'token', 'verify'].join('/'),
|
||||
adduser: () => [host, prefix, 'users', 'add'].join('/'),
|
||||
deluser: () => [host, prefix, 'users'].join('/'),
|
||||
change_password_url: () => [host, prefix, 'users', 'password'].join('/'),
|
||||
change_user: () => [host, prefix, 'users'].join('/')
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
const hashPassword = async (password) => {
|
||||
if (crypto && crypto.subtle) {
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(password)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
const hashHex = hashArray.map(b=>('00' + b.toString(16)).slice(-2)).join('')
|
||||
return hashHex
|
||||
}
|
||||
return CryptoJS.SHA256(password).toString()
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
const themecheckbox = document.querySelector('#themecheckbox')
|
||||
|
||||
themecheckbox.addEventListener('click', (e) => {
|
||||
if (themecheckbox.checked) {
|
||||
document.documentElement.setAttribute('data-bs-theme', 'dark')
|
||||
localStorage.setItem('bs-theme', 'dark')
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-bs-theme', 'light')
|
||||
localStorage.setItem('bs-theme', 'light')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
var theme = localStorage.getItem("bs-theme");
|
||||
if (theme) {
|
||||
document.documentElement.setAttribute("data-bs-theme", theme);
|
||||
if (theme === "dark") {
|
||||
themecheckbox.checked = true;
|
||||
}
|
||||
}
|
||||
if (localStorage.getItem("token")) {
|
||||
fetch(routes.access(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ token: localStorage.getItem("token") }),
|
||||
})
|
||||
.then((data) => data.json())
|
||||
.then((jsondata) => {
|
||||
if (jsondata.success) {
|
||||
document.querySelector(".loginform").classList.remove("active");
|
||||
get_all_users();
|
||||
} else {
|
||||
document.querySelector(".loginform").classList.add("active");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
notification(`Ошибка на сервере: ${error}`, "error");
|
||||
});
|
||||
usernameheader.innerHTML = JSON.parse(
|
||||
localStorage.getItem("userdata"),
|
||||
).username;
|
||||
} else {
|
||||
document.querySelector(".loginform").classList.add("active");
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user