diff --git a/frontend/package.json b/frontend/package.json index 84656f8..e9d0822 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "axios": "^1.15.0", "pinia": "^3.0.4", "vue": "^3.5.31", + "vue-i18n": "^9.14.5", "vue-router": "^4.6.4" }, "devDependencies": { diff --git a/frontend/src/components/Layout/AppLayout.vue b/frontend/src/components/Layout/AppLayout.vue index e877598..c0166f2 100644 --- a/frontend/src/components/Layout/AppLayout.vue +++ b/frontend/src/components/Layout/AppLayout.vue @@ -1,11 +1,14 @@ diff --git a/frontend/src/views/Profile.vue b/frontend/src/views/Profile.vue new file mode 100644 index 0000000..4a4b761 --- /dev/null +++ b/frontend/src/views/Profile.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/frontend/src/views/Restaurants.vue b/frontend/src/views/Restaurants.vue index 373188d..cc80b0e 100644 --- a/frontend/src/views/Restaurants.vue +++ b/frontend/src/views/Restaurants.vue @@ -2,89 +2,143 @@

Restaurants

- +
-
- - - - - - - - - - - - - - - - - - - - - - - -
IDNameHostHTTPSLoginCreatedActions
{{ rest.id }}{{ rest.name }}{{ rest.host }} - - {{ rest.login }}{{ formatDate(rest.created) }} - - -
-
- - -
-
-

{{ modalTitle }}

-
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -

Leave blank to keep current password

-
-
- - -
-
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
IDNameHostHTTPSLoginCreatedActions
{{ rest.id }}{{ rest.name }}{{ rest.host }} + + {{ rest.login }}{{ formatDate(rest.created) }} + + +
No restaurants found. Click "Add Restaurant" to create one.
+ + + +
+
+
+
+
+

{{ modalTitle }}

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +

Leave blank to keep current password

+
+
+ + +
+
+
+
+
+
+ + + +
+
+
+
+
+
+ + + +
+

Delete Restaurant

+

Are you sure you want to delete this restaurant? This action cannot be undone.

+
+ + +
+
+
+
+
+
@@ -97,6 +151,7 @@ const modalOpen = ref(false); const modalMode = ref<'create' | 'edit'>('create'); const form = ref({ id: null, name: '', login: '', password: '', host: '', https: false }); const modalTitle = ref(''); +const deleteConfirm = ref({ show: false, id: null }); async function loadRestaurants() { const res = await fetch('/api/admin/restaurants'); @@ -138,7 +193,6 @@ async function toggleHttps(rest: any) { host: rest.host, login: rest.login, https: newHttps - // пароль не передаём, он останется прежним }; try { const res = await fetch(`/api/admin/restaurants/${rest.id}`, { @@ -147,9 +201,7 @@ async function toggleHttps(rest: any) { body: JSON.stringify(payload) }); if (res.ok) { - // Обновляем локальное состояние или перезагружаем список rest.https = newHttps; - // Альтернатива: await loadRestaurants(); } else { alert('Failed to update HTTPS status'); } @@ -193,12 +245,26 @@ async function submitRestaurant() { } } +function confirmDelete(id: number) { + deleteConfirm.value = { show: true, id }; +} + async function deleteRestaurant(id: number) { - if (confirm('Are you sure?')) { - await fetch(`/api/admin/restaurants/${id}`, { method: 'DELETE' }); - await loadRestaurants(); - } + await fetch(`/api/admin/restaurants/${id}`, { method: 'DELETE' }); + await loadRestaurants(); + deleteConfirm.value.show = false; } onMounted(loadRestaurants); + + diff --git a/frontend/src/views/Users.vue b/frontend/src/views/Users.vue index 170d6e9..81b302c 100644 --- a/frontend/src/views/Users.vue +++ b/frontend/src/views/Users.vue @@ -1,87 +1,169 @@ + + diff --git a/frontend/src/views/auth/Login.vue b/frontend/src/views/auth/Login.vue index 66e95a5..c38e7b9 100644 --- a/frontend/src/views/auth/Login.vue +++ b/frontend/src/views/auth/Login.vue @@ -104,9 +104,10 @@ import { ref } from 'vue' import { useRouter } from 'vue-router' import { useSettingsStore } from '../../stores/settings' +import { useUserStore } from '../../stores/user' const settings = useSettingsStore() - +const userStore = useUserStore() const router = useRouter() const form = ref({ login: '', password: '' }) const loading = ref(false) @@ -116,24 +117,19 @@ const showPassword = ref(false) async function handleLogin() { loading.value = true error.value = '' - try { const res = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form.value) }) - if (res.ok) { + // Загружаем профиль (роль, язык, email) + await userStore.fetchProfile() router.push('/dashboard') } else { - // Пытаемся получить текст ошибки от сервера const text = await res.text() - if (text && text.trim()) { - error.value = text - } else { - error.value = 'Invalid username or password' - } + error.value = text || 'Invalid username or password' } } catch (e) { error.value = 'Network error. Please try again.' diff --git a/src/main/java/su/xserver/iikocon/MainVerticle.java b/src/main/java/su/xserver/iikocon/MainVerticle.java index 08bf3ef..4b7238c 100644 --- a/src/main/java/su/xserver/iikocon/MainVerticle.java +++ b/src/main/java/su/xserver/iikocon/MainVerticle.java @@ -18,6 +18,7 @@ import io.vertx.ext.web.sstore.SessionStore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import su.xserver.iikocon.config.AppConfig; +import su.xserver.iikocon.handler.AdminHandler; import su.xserver.iikocon.handler.AuthHandler; import su.xserver.iikocon.handler.SecurityHandler; import su.xserver.iikocon.handler.SetupHandler; @@ -194,7 +195,50 @@ public class MainVerticle extends AbstractVerticle { .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); })); + // В initRouter после настройки authHandler, до объявления /api/admin/*: + router.route("/api/admin/profile").handler(authHandler::requireAuth); + router.get("/api/admin/profile").handler(rc -> { + Integer userId = rc.session().get("userId"); + userService.getProfile(userId) + .onSuccess(profile -> rc.response().putHeader("Content-Type", "application/json").end(profile.encode())) + .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); + }); + router.put("/api/admin/profile").handler(rc -> { + Integer userId = rc.session().get("userId"); + JsonObject body = rc.body().asJsonObject(); + String email = body.getString("email"); + String password = body.getString("password"); + String language = body.getString("language"); + userService.updateProfile(userId, email, password, language) + .onSuccess(v -> { + if (language != null) rc.session().put("language", language); + rc.response().end(new JsonObject().put("success", true).encode()); + }) + .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); + }); + router.put("/api/admin/language").handler(rc -> { + Integer userId = rc.session().get("userId"); + JsonObject body = rc.body().asJsonObject(); + String language = body.getString("language"); + if (language == null || (!"en".equals(language) && !"ru".equals(language))) { + rc.response().setStatusCode(400).end("Invalid language"); + return; + } + userService.updateLanguage(userId, language) + .onSuccess(v -> { + rc.session().put("language", language); + rc.response().end(new JsonObject().put("success", true).encode()); + }) + .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); + }); + + // Затем существующий блок router.route("/api/admin/*").handler(authHandler::requireAuth); router.route("/api/admin/*").handler(authHandler::requireAuth); + // Добавить проверку роли для чувствительных эндпоинтов: +// router.route("/api/admin/users*").handler(AdminHandler::requireAdmin); +// router.route("/api/admin/restaurants*").handler(AdminHandler::requireAdmin); +// router.route("/api/admin/settings*").handler(AdminHandler::requireAdmin); +// router.route("/api/admin/active-sessions").handler(AdminHandler::requireAdmin); router.get("/api/admin/users").handler(rc -> userService.getAllUsers().onComplete(ar -> { if (ar.succeeded()) { @@ -211,13 +255,14 @@ public class MainVerticle extends AbstractVerticle { String login = body.getString("login"); String email = body.getString("email"); String password = body.getString("password"); + String role = body.getString("role"); String ip = rc.request().remoteAddress().host(); if (login == null || email == null || password == null) { rc.response().setStatusCode(400).end("Missing login, email or password"); return; } - // Создаём активного пользователя (active = true) - userService.createUser(login, email, password, ip, true) + if (role == null || role.isEmpty()) role = "user"; + userService.createUser(login, email, password, ip, true, role) .onSuccess(v -> rc.response().setStatusCode(201).end()) .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); }); @@ -228,12 +273,13 @@ public class MainVerticle extends AbstractVerticle { String login = body.getString("login"); String email = body.getString("email"); String password = body.getString("password"); + String role = body.getString("role"); String ip = rc.request().remoteAddress().host(); if (login == null || email == null) { rc.response().setStatusCode(400).end("Missing login or email"); return; } - userService.updateUser(id, login, email, password, ip) + userService.updateUser(id, login, email, password, ip, role) .onSuccess(v -> rc.response().end()) .onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage())); }); diff --git a/src/main/java/su/xserver/iikocon/handler/AdminHandler.java b/src/main/java/su/xserver/iikocon/handler/AdminHandler.java new file mode 100644 index 0000000..326d3d7 --- /dev/null +++ b/src/main/java/su/xserver/iikocon/handler/AdminHandler.java @@ -0,0 +1,15 @@ +package su.xserver.iikocon.handler; + +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.RoutingContext; + +public class AdminHandler { + public static void requireAdmin(RoutingContext ctx) { + String role = ctx.session().get("role"); + if (!"admin".equals(role)) { + ctx.response().setStatusCode(403).end(new JsonObject().put("error", "Admin access required").encode()); + return; + } + ctx.next(); + } +} diff --git a/src/main/java/su/xserver/iikocon/handler/AuthHandler.java b/src/main/java/su/xserver/iikocon/handler/AuthHandler.java index 4268bf0..42a67aa 100644 --- a/src/main/java/su/xserver/iikocon/handler/AuthHandler.java +++ b/src/main/java/su/xserver/iikocon/handler/AuthHandler.java @@ -53,6 +53,8 @@ public class AuthHandler { Session session = ctx.session(); session.put("userId", user.getInteger("id")); session.put("login", user.getString("login")); + session.put("role", user.getString("role")); + session.put("language", user.getString("language")); ctx.response().end(new JsonObject().put("success", true).put("login", user.getString("login")).encode()); } else { ctx.response().setStatusCode(401).end("Invalid credentials"); diff --git a/src/main/java/su/xserver/iikocon/handler/SetupHandler.java b/src/main/java/su/xserver/iikocon/handler/SetupHandler.java index e0ed5f7..92d3edb 100644 --- a/src/main/java/su/xserver/iikocon/handler/SetupHandler.java +++ b/src/main/java/su/xserver/iikocon/handler/SetupHandler.java @@ -56,7 +56,7 @@ public class SetupHandler { if (clientIp == null) { clientIp = ctx.request().remoteAddress().host(); } - userService.createUser(login, email, password, clientIp, true).onComplete(cr -> { + userService.createUser(login, email, password, clientIp, true, "admin").onComplete(cr -> { if (cr.succeeded()) { ctx.response().setStatusCode(201) .end(new JsonObject().put("success", true).encode()); diff --git a/src/main/java/su/xserver/iikocon/service/UserService.java b/src/main/java/su/xserver/iikocon/service/UserService.java index 2a4c251..d8a469d 100644 --- a/src/main/java/su/xserver/iikocon/service/UserService.java +++ b/src/main/java/su/xserver/iikocon/service/UserService.java @@ -28,6 +28,8 @@ public class UserService { email VARCHAR(255) UNIQUE NOT NULL, password VARCHAR(255) NOT NULL, active BOOLEAN DEFAULT FALSE, + role VARCHAR(50) DEFAULT 'user', + language VARCHAR(5) DEFAULT 'en', ip VARCHAR(45), created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP @@ -42,24 +44,28 @@ public class UserService { .map(rows -> rows.iterator().next().getLong("cnt")); } - public Future createUser(String login, String email, String password, String ip, boolean active) { + public Future createUser(String login, String email, String password, String ip, boolean active, String role) { String hash = BCrypt.hashpw(password, BCrypt.gensalt()); Map params = Map.of( "login", login, "email", email, "password", hash, "ip", ip, - "active", active + "active", active, + "role", role ); return SqlTemplate.forUpdate(pool, - "INSERT INTO users (login, email, password, ip, active) VALUES (#{login}, #{email}, #{password}, #{ip}, #{active})") + "INSERT INTO users (login, email, password, ip, active, role) VALUES (#{login}, #{email}, #{password}, #{ip}, #{active}, #{role})") .execute(params) .mapEmpty(); } - // Существующий метод оставляем, но он будет создавать неактивного пользователя (active = false) + public Future createUser(String login, String email, String password, String ip, boolean active) { + return createUser(login, email, password, ip, active, "user"); + } + public Future createUser(String login, String email, String password, String ip) { - return createUser(login, email, password, ip, false); + return createUser(login, email, password, ip, false, "user"); } public Future setActive(int id, boolean active) { @@ -68,7 +74,7 @@ public class UserService { } public Future findByLoginOrEmail(String loginOrEmail) { - String sql = "SELECT id, login, email, password, active, ip, created, updated FROM users WHERE login = ? OR email = ?"; + String sql = "SELECT id, login, email, password, active, role, language, ip, created, updated FROM users WHERE login = ? OR email = ?"; return pool.preparedQuery(sql) .execute(Tuple.of(loginOrEmail, loginOrEmail)) .map(rows -> { @@ -80,31 +86,8 @@ public class UserService { }); } - public Future findByEmail(String email) { - return SqlTemplate.forQuery(pool, "SELECT id, login, email, password, active, ip, created, updated FROM users WHERE email = #{email}") - .mapTo(this::toJson) - .execute(Map.of("email", email)) - .map(rows -> rows.iterator().hasNext() ? rows.iterator().next() : null); - } - - public Future findByLogin(String login) { - return SqlTemplate.forQuery(pool, - "SELECT id, login, password, created, updated, ip FROM users WHERE login = #{login}") - .mapTo(row -> new JsonObject() - .put("id", row.getInteger("id")) - .put("login", row.getString("login")) - .put("password", row.getString("password")) - .put("created", row.getLocalDateTime("created") != null ? - row.getLocalDateTime("created").toString() : null) - .put("updated", row.getLocalDateTime("updated") != null ? - row.getLocalDateTime("updated").toString() : null) - .put("ip", row.getString("ip"))) - .execute(Collections.singletonMap("login", login)) - .map(rows -> rows.iterator().hasNext() ? rows.iterator().next() : null); - } - public Future getAllUsers() { - return pool.query("SELECT id, login, email, active, ip, created, updated FROM users ORDER BY id") + return pool.query("SELECT id, login, email, active, role, language, ip, created, updated FROM users ORDER BY id") .execute() .map(rows -> { JsonArray array = new JsonArray(); @@ -114,6 +97,8 @@ public class UserService { .put("login", row.getString("login")) .put("email", row.getString("email")) .put("active", row.getBoolean("active")) + .put("role", row.getString("role")) + .put("language", row.getString("language")) .put("ip", row.getString("ip")) .put("created", row.getLocalDateTime("created") != null ? row.getLocalDateTime("created").toString() : null) .put("updated", row.getLocalDateTime("updated") != null ? row.getLocalDateTime("updated").toString() : null)); @@ -122,19 +107,23 @@ public class UserService { }); } - public Future updateUser(int id, String login, String email, String password, String ip) { + public Future updateUser(int id, String login, String email, String password, String ip, String role) { Map params = new HashMap<>(); params.put("id", id); params.put("login", login); params.put("email", email); params.put("ip", ip); + if (role != null) params.put("role", role); + String sql; if (password != null && !password.isEmpty()) { String hash = BCrypt.hashpw(password, BCrypt.gensalt()); params.put("password", hash); - sql = "UPDATE users SET login = #{login}, email = #{email}, password = #{password}, ip = #{ip} WHERE id = #{id}"; + sql = "UPDATE users SET login = #{login}, email = #{email}, password = #{password}, ip = #{ip}" + + (role != null ? ", role = #{role}" : "") + " WHERE id = #{id}"; } else { - sql = "UPDATE users SET login = #{login}, email = #{email}, ip = #{ip} WHERE id = #{id}"; + sql = "UPDATE users SET login = #{login}, email = #{email}, ip = #{ip}" + + (role != null ? ", role = #{role}" : "") + " WHERE id = #{id}"; } return SqlTemplate.forUpdate(pool, sql).execute(params).mapEmpty(); } @@ -151,6 +140,44 @@ public class UserService { .mapEmpty(); } + public Future getProfile(int userId) { + return SqlTemplate.forQuery(pool, + "SELECT id, login, email, role, language, ip, created, updated FROM users WHERE id = #{id}") + .mapTo(row -> new JsonObject() + .put("id", row.getInteger("id")) + .put("login", row.getString("login")) + .put("email", row.getString("email")) + .put("role", row.getString("role")) + .put("language", row.getString("language")) + .put("ip", row.getString("ip")) + .put("created", row.getLocalDateTime("created") != null ? row.getLocalDateTime("created").toString() : null) + .put("updated", row.getLocalDateTime("updated") != null ? row.getLocalDateTime("updated").toString() : null)) + .execute(Map.of("id", userId)) + .map(rows -> rows.iterator().hasNext() ? rows.iterator().next() : null); + } + + public Future updateProfile(int userId, String email, String password, String language) { + Map params = new HashMap<>(); + params.put("id", userId); + params.put("email", email); + if (language != null) params.put("language", language); + String sql; + if (password != null && !password.isEmpty()) { + String hash = BCrypt.hashpw(password, BCrypt.gensalt()); + params.put("password", hash); + sql = "UPDATE users SET email = #{email}, password = #{password}, language = COALESCE(#{language}, language) WHERE id = #{id}"; + } else { + sql = "UPDATE users SET email = #{email}, language = COALESCE(#{language}, language) WHERE id = #{id}"; + } + return SqlTemplate.forUpdate(pool, sql).execute(params).mapEmpty(); + } + + public Future updateLanguage(int userId, String language) { + return SqlTemplate.forUpdate(pool, "UPDATE users SET language = #{lang} WHERE id = #{id}") + .execute(Map.of("id", userId, "lang", language)) + .mapEmpty(); + } + public boolean checkPassword(String plain, String hash) { try { return BCrypt.checkpw(plain, hash); @@ -164,8 +191,10 @@ public class UserService { .put("id", row.getInteger("id")) .put("login", row.getString("login")) .put("email", row.getString("email")) - .put("password", row.getString("password")) // ← ДОБАВИТЬ ЭТУ СТРОКУ + .put("password", row.getString("password")) .put("active", row.getBoolean("active")) + .put("role", row.getString("role")) + .put("language", row.getString("language")) .put("ip", row.getString("ip")) .put("created", row.getLocalDateTime("created") != null ? row.getLocalDateTime("created").toString() : null) .put("updated", row.getLocalDateTime("updated") != null ? row.getLocalDateTime("updated").toString() : null);