Compare commits
17 Commits
a85cdc6356
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a283d755de | |||
| 4da6e6fb5f | |||
| 0fbbfdc997 | |||
| b1061e67ac | |||
| ae31f57ee0 | |||
| 28955feda7 | |||
| c192eeba65 | |||
| 45778d83d0 | |||
| bf3cebcf86 | |||
| 478f2429f5 | |||
| f6a10c8133 | |||
| 386472e680 | |||
| 9cb34839ec | |||
| bc5c04c7be | |||
| 702bb94004 | |||
| b455fdb20e | |||
| 34b7b47d0e |
+83
-1
@@ -1,3 +1,85 @@
|
|||||||
|
# Übersicht der Inhalt
|
||||||
|
|
||||||
|
### Fett markierte Verzeichnisse und deren Inhalt sind von besonderem Interesse
|
||||||
|
|
||||||
|
- **api**
|
||||||
|
- docs
|
||||||
|
- API Dokumentation (openAPI 3.0)
|
||||||
|
- Login
|
||||||
|
- `Login.php` Endpunkt Klasse für `api/api/login`
|
||||||
|
- Logout
|
||||||
|
- `Logout.php` Endpunkt Klasse für `api/api/login`
|
||||||
|
- Posts
|
||||||
|
- `Posts.php` Endpunkt Klasse für `api/api/posts`
|
||||||
|
- `Posts.php` Endpunkt Klasse für `api/api/posts/{id}`
|
||||||
|
- Refresh
|
||||||
|
- `Refresh.php` Endpunkt Klasse für `api/api/refresh`
|
||||||
|
- Register
|
||||||
|
- `Register.php` Endpunkt Klasse für `api/api/register`
|
||||||
|
- Users
|
||||||
|
- `Users.php` Endpunkt Klasse für `api/users`
|
||||||
|
- `Users.php` Endpunkt Klasse für `api/users/{id}`
|
||||||
|
- Image
|
||||||
|
- `Image.php` Endpunkt Klasse für `api/users{id}/image`
|
||||||
|
- Permissions
|
||||||
|
- `Permissions.php` Endpunkt Klasse für `api/users/{id}/permissions`
|
||||||
|
- Posts
|
||||||
|
- `Posts.php` Endpunkt Klasse für `api/users{id}/posts`
|
||||||
|
- `index.php`
|
||||||
|
- API Einstiegspunkt
|
||||||
|
- **classes**
|
||||||
|
- Api
|
||||||
|
- `Api.php`: Basisklasse für Endpunkte
|
||||||
|
- ApiError
|
||||||
|
- `ApiError.php`: Facade für Fehler die das Api zurück gibt
|
||||||
|
- Auth
|
||||||
|
- `AdminAuth.php`: Middleware für Authentifizierung zuzüglich Admin Rechte
|
||||||
|
- `Auth.php`: Middleware für Authentifizierung
|
||||||
|
- `OptAuth.php`: Middleware für optionale Authentifizierung
|
||||||
|
- Config
|
||||||
|
- `Config.php`: Singleton für Applikationskonfiguration
|
||||||
|
- Database
|
||||||
|
- `Database.php`: Singleton für Datenbankzugriffe (Wrapper um PDO)
|
||||||
|
- GUID
|
||||||
|
- `GUID.php`: Facade für GUID Algorithmen
|
||||||
|
- Input
|
||||||
|
- `Input.php`: Facade die SimpleRouters `Input` Klasse wrapped für
|
||||||
|
einfacheren Zugriff auf häufig verwendete Methoden
|
||||||
|
- Models
|
||||||
|
- Post
|
||||||
|
- `Post.php`: Modellklasse für Posts. Abstrahiert und kapselt
|
||||||
|
Datenbankzugriffe
|
||||||
|
- User
|
||||||
|
- `User.php`: Modellklasse für User. Abstrahiert und kapselt
|
||||||
|
Datenbankzugriffe
|
||||||
|
- Request
|
||||||
|
- `Request.php`: Facade die SimpleRouters `Request` Klasse wrapped für
|
||||||
|
einfacheren Zugriff auf häufig verwendete Methoden
|
||||||
|
- Response
|
||||||
|
- `Response.php`: Facade die SimpleRouters `Response` Klasse wrapped für
|
||||||
|
einfacheren Zugriff auf häufig verwendete Methoden
|
||||||
|
- **config**
|
||||||
|
- `app.php`: Applikationskonfiguration
|
||||||
|
- `database.php`: Datenbankverbindungsdaten
|
||||||
|
- dist
|
||||||
|
- React Buildartefakte.
|
||||||
|
- react
|
||||||
|
- React Projekt
|
||||||
|
- **routes**
|
||||||
|
- `routes.php`: SimpleRouter Routenkonfiguration für die Applikation
|
||||||
|
- storage:
|
||||||
|
- Speicherort für Profilbilder
|
||||||
|
- **utils**:
|
||||||
|
- `helpers.php`: Kleine Helferlein
|
||||||
|
- vendor:
|
||||||
|
- Verzeichnis für Composer Pakete
|
||||||
|
|
||||||
|
# Autoloader
|
||||||
|
|
||||||
|
Verwendet wird hier der Composer Autoloader. Konfiguriert werden dessen
|
||||||
|
Namespaces in der `composer.json`. Durch `composer dump-autoload` wird
|
||||||
|
der Autoloader aktualisiert sollten neue Namespaces hinzugefügt werden.
|
||||||
|
|
||||||
# Voraussetzungen um das React Projekt zu bauen
|
# Voraussetzungen um das React Projekt zu bauen
|
||||||
- Node 18
|
- Node 18
|
||||||
- PNPM
|
- PNPM
|
||||||
@@ -18,7 +100,7 @@
|
|||||||
## Tabelle `egb_gaestebuch`
|
## Tabelle `egb_gaestebuch`
|
||||||
- Abänderung der Spalte `benutzer_id`: Non-Nullable gemacht
|
- Abänderung der Spalte `benutzer_id`: Non-Nullable gemacht
|
||||||
- Abänderung der Spalte `beitrag`: Non-Nullable gemacht
|
- Abänderung der Spalte `beitrag`: Non-Nullable gemacht
|
||||||
- Hinzufüge eines Foreign Key Constraints auf `benutzer_id`
|
- Hinzufüge eines Foreign Key Constraints auf `benutzer_id` mit CASCADE (Löschen des Users löscht Beiträge)
|
||||||
|
|
||||||
# Notwendige Anpassung für die Verzeichnisstruktur eines anderen Hosters
|
# Notwendige Anpassung für die Verzeichnisstruktur eines anderen Hosters
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Api\Users\Permissions;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use Khofmann\Api\Api;
|
||||||
|
use Khofmann\Input\Input;
|
||||||
|
use Khofmann\Models\User\User;
|
||||||
|
use Khofmann\Response\Response;
|
||||||
|
use Khofmann\ApiError\ApiError;
|
||||||
|
use Khofmann\Request\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User permissions route handlers
|
||||||
|
*/
|
||||||
|
class Permissions extends Api
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Permissions PATCH handler
|
||||||
|
*
|
||||||
|
* Sets user admin or not.
|
||||||
|
*
|
||||||
|
* Returns updated user.
|
||||||
|
*
|
||||||
|
* @param mixed $id User ID
|
||||||
|
*
|
||||||
|
* @throws 404 User not found
|
||||||
|
* @throws 500 Failed to update user permissions
|
||||||
|
*/
|
||||||
|
public function patch($id): void
|
||||||
|
{
|
||||||
|
// Fetch all inputs.
|
||||||
|
$isAdmin = Input::post("isAdmin");
|
||||||
|
|
||||||
|
// Try and update user image.
|
||||||
|
// Throw errors according to situation.
|
||||||
|
try {
|
||||||
|
Response::json(User::getByID($id)->updatePermissions($isAdmin));
|
||||||
|
} catch (Exception $err) {
|
||||||
|
switch ($err->getMessage()) {
|
||||||
|
case "NotFound":
|
||||||
|
throw ApiError::notFound("user");
|
||||||
|
default:
|
||||||
|
// Due to how the failed field is handled, it's ApiError is inside the models update
|
||||||
|
throw $err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -640,6 +640,57 @@ paths:
|
|||||||
}
|
}
|
||||||
tags:
|
tags:
|
||||||
- User
|
- User
|
||||||
|
/users/{id}/permissions:
|
||||||
|
post:
|
||||||
|
summary: Update user permissions
|
||||||
|
description: Update user permissions with ID.
|
||||||
|
security:
|
||||||
|
- BasicAuth: [isAdmin]
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
description: User ID
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
format: int14
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/UserPermissionsUpdateRequest"
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Success.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/UserResponse"
|
||||||
|
404:
|
||||||
|
description: User not found.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/NotFoundResponse"
|
||||||
|
examples:
|
||||||
|
User not found:
|
||||||
|
value: { "code": "NotFound", "entity": "user" }
|
||||||
|
500:
|
||||||
|
description: Update failed.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/FailedUpdateResponse"
|
||||||
|
examples:
|
||||||
|
Failed username:
|
||||||
|
value:
|
||||||
|
{
|
||||||
|
"code": "FailedUpdate",
|
||||||
|
"fields": ["isAdmin"],
|
||||||
|
"reasons": ["string"],
|
||||||
|
}
|
||||||
|
tags:
|
||||||
|
- User
|
||||||
/users/{id}/posts:
|
/users/{id}/posts:
|
||||||
get:
|
get:
|
||||||
summary: Get user posts
|
summary: Get user posts
|
||||||
@@ -816,6 +867,11 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
email:
|
email:
|
||||||
type: string
|
type: string
|
||||||
|
UserPermissionsUpdateRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
isAdmin:
|
||||||
|
type: boolean
|
||||||
UserImageUpdateRequest:
|
UserImageUpdateRequest:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -339,7 +339,7 @@ class User implements JsonSerializable
|
|||||||
$data
|
$data
|
||||||
);
|
);
|
||||||
|
|
||||||
return ["pages" => intdiv($count, $limit + 1), "data" => $list];
|
return ["pages" => intdiv($count, $limit + 1) + 1, "data" => $list];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -425,7 +425,7 @@ class User implements JsonSerializable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update post
|
* Update user
|
||||||
*
|
*
|
||||||
* Does nothing if new all fields are empty
|
* Does nothing if new all fields are empty
|
||||||
*
|
*
|
||||||
@@ -515,7 +515,7 @@ class User implements JsonSerializable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update post
|
* Update user
|
||||||
*
|
*
|
||||||
* Does nothing if all fields are empty
|
* Does nothing if all fields are empty
|
||||||
*
|
*
|
||||||
@@ -528,26 +528,21 @@ class User implements JsonSerializable
|
|||||||
{
|
{
|
||||||
$db = Database::getInstance();
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// Grab old picture
|
||||||
|
$stmt = $db->prepare("SELECT image FROM egb_benutzer WHERE id = :ID");
|
||||||
|
$stmt->bindValue(":ID", $this->id);
|
||||||
|
$stmt->execute();
|
||||||
|
$oldImage = $stmt->fetch(PDO::FETCH_COLUMN, 0);
|
||||||
|
|
||||||
// Make sure we do all changes or none
|
// Make sure we do all changes or none
|
||||||
$db->beginTransaction();
|
$db->beginTransaction();
|
||||||
|
|
||||||
$failed = [];
|
$failed = [];
|
||||||
$reasons = [];
|
$reasons = [];
|
||||||
|
|
||||||
try {
|
|
||||||
$stmt = $db->prepare("SELECT image FROM egb_benutzer WHERE id = :ID");
|
|
||||||
$stmt->bindValue(":ID", $this->id);
|
|
||||||
$stmt->execute();
|
|
||||||
$oldImage = $stmt->fetch(PDO::FETCH_COLUMN, 0);
|
|
||||||
if (strpos($oldImage, "default") === false) unlink(Config::getStorageFSPath() . $oldImage);
|
|
||||||
} catch (Exception $e) {
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($image)) {
|
if (!empty($image)) {
|
||||||
// Move file and grab filename
|
// Generate new filename
|
||||||
$destinationFilename = sprintf('%s.%s', uniqid(), $image->getExtension());
|
$destinationFilename = sprintf('%s.%s', uniqid(), $image->getExtension());
|
||||||
$image->move(Config::getStorageFSPath() . "profilbilder/$destinationFilename");
|
// Try and update user record
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$stmt = $db->prepare("UPDATE egb_benutzer SET image = :IMG WHERE id = :ID");
|
$stmt = $db->prepare("UPDATE egb_benutzer SET image = :IMG WHERE id = :ID");
|
||||||
$stmt->bindValue(":IMG", "profilbilder/$destinationFilename");
|
$stmt->bindValue(":IMG", "profilbilder/$destinationFilename");
|
||||||
@@ -557,6 +552,8 @@ class User implements JsonSerializable
|
|||||||
array_push($failed, "image");
|
array_push($failed, "image");
|
||||||
array_push($reasons, "generic");
|
array_push($reasons, "generic");
|
||||||
}
|
}
|
||||||
|
// Move file
|
||||||
|
$image->move(Config::getStorageFSPath() . "profilbilder/$destinationFilename");
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
array_push($failed, "image");
|
array_push($failed, "image");
|
||||||
if ($e->getCode() === "23000") {
|
if ($e->getCode() === "23000") {
|
||||||
@@ -594,11 +591,79 @@ class User implements JsonSerializable
|
|||||||
// Commit the changes
|
// Commit the changes
|
||||||
$db->commit();
|
$db->commit();
|
||||||
|
|
||||||
|
// Delete old picture if it isn't a default one and if it exists.
|
||||||
|
try {
|
||||||
|
if (strpos($oldImage, "default") === false && is_file(Config::getStorageFSPath() . $oldImage)) unlink(Config::getStorageFSPath() . $oldImage);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
}
|
||||||
|
|
||||||
return User::getByID($this->id);
|
return User::getByID($this->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete user
|
* Update user
|
||||||
|
*
|
||||||
|
* Does nothing if new all fields are empty
|
||||||
|
*
|
||||||
|
* @param ?bool $isAdmin Admin permission
|
||||||
|
*
|
||||||
|
* @throws Failed Failed to update admin status
|
||||||
|
*/
|
||||||
|
public function updatePermissions(?bool $isAdmin): User
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// Make sure we do all changes or none
|
||||||
|
$db->beginTransaction();
|
||||||
|
|
||||||
|
$failed = [];
|
||||||
|
$reasons = [];
|
||||||
|
if (isset($isAdmin)) {
|
||||||
|
// Clear tokens to revoke access if logged in
|
||||||
|
$stmt = $db->prepare(
|
||||||
|
"UPDATE
|
||||||
|
egb_benutzer
|
||||||
|
SET
|
||||||
|
isadmin = :ADM,
|
||||||
|
token = NULL,
|
||||||
|
tokenExpiry = NULL,
|
||||||
|
refreshToken = NULL,
|
||||||
|
refreshExpiry = NULL
|
||||||
|
WHERE
|
||||||
|
id = :ID"
|
||||||
|
);
|
||||||
|
$stmt->bindValue(":ADM", $isAdmin);
|
||||||
|
$stmt->bindValue(":ID", $this->id);
|
||||||
|
try {
|
||||||
|
if (!$stmt->execute()) {
|
||||||
|
array_push($failed, "username");
|
||||||
|
array_push($reasons, "generic");
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
array_push($failed, "username");
|
||||||
|
if ($e->getCode() === "23000") {
|
||||||
|
$pdoErr = $stmt->errorInfo()[1];
|
||||||
|
if ($pdoErr === 1062) array_push($reasons, "Duplicate");
|
||||||
|
else array_push($reasons, "SQL: $pdoErr");
|
||||||
|
} else array_push($reasons, "{$e->getCode()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($failed) > 0) {
|
||||||
|
// We failed, go back
|
||||||
|
$db->rollBack();
|
||||||
|
|
||||||
|
throw ApiError::failedUpdate($failed, $reasons);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit the changes
|
||||||
|
$db->commit();
|
||||||
|
|
||||||
|
return User::getByID($this->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete user and image
|
||||||
*
|
*
|
||||||
* @param int $limit Limit of list for which the returned pages is calculated.
|
* @param int $limit Limit of list for which the returned pages is calculated.
|
||||||
*
|
*
|
||||||
@@ -609,6 +674,7 @@ class User implements JsonSerializable
|
|||||||
$db = Database::getInstance();
|
$db = Database::getInstance();
|
||||||
$stmt = $db->prepare("DELETE FROM egb_benutzer WHERE id = :ID");
|
$stmt = $db->prepare("DELETE FROM egb_benutzer WHERE id = :ID");
|
||||||
$stmt->bindValue(":ID", $this->id);
|
$stmt->bindValue(":ID", $this->id);
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
$stmt = $db->prepare(
|
$stmt = $db->prepare(
|
||||||
"SELECT
|
"SELECT
|
||||||
@@ -619,6 +685,12 @@ class User implements JsonSerializable
|
|||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$count = $stmt->fetch(PDO::FETCH_COLUMN, 0);
|
$count = $stmt->fetch(PDO::FETCH_COLUMN, 0);
|
||||||
|
|
||||||
|
// Delete picture if it isn't a default one and if it exists.
|
||||||
|
try {
|
||||||
|
if (strpos($this->image, "default") === false && is_file(Config::getStorageFSPath() . $this->image)) unlink(Config::getStorageFSPath() . $this->image);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
}
|
||||||
|
|
||||||
return ["pages" => intdiv($count, $limit + 1) + 1, "data" => $this];
|
return ["pages" => intdiv($count, $limit + 1) + 1, "data" => $this];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Vendored
-9
File diff suppressed because one or more lines are too long
Vendored
+9
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -23,10 +23,10 @@
|
|||||||
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>GuestBook</title>
|
<title>GuestBook</title>
|
||||||
<script type="module" crossorigin src="/phpCourse/exam/dist/assets/index-ClGBs-iO.js"></script>
|
<script type="module" crossorigin src="/phpCourse/exam/dist/assets/index-SI5snNZz.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/react-C9_qfvjK.js">
|
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/react-C9_qfvjK.js">
|
||||||
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/mui-BnAUJOoN.js">
|
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/mui-BnAUJOoN.js">
|
||||||
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/tanstack-BqkrhB-y.js">
|
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/tanstack-DpDh5IPY.js">
|
||||||
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/zustand-DKxXQGKw.js">
|
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/zustand-DKxXQGKw.js">
|
||||||
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/i18n-Be01V9yD.js">
|
<link rel="modulepreload" crossorigin href="/phpCourse/exam/dist/assets/i18n-Be01V9yD.js">
|
||||||
<link rel="stylesheet" crossorigin href="/phpCourse/exam/dist/assets/mui-CKDNpdid.css">
|
<link rel="stylesheet" crossorigin href="/phpCourse/exam/dist/assets/mui-CKDNpdid.css">
|
||||||
|
|||||||
+16
-1
@@ -20,6 +20,9 @@
|
|||||||
"NotAllowed_postUpdate": "Keine Berechtigung",
|
"NotAllowed_postUpdate": "Keine Berechtigung",
|
||||||
"NotFound_post:postUpdate": "Post nicht gefunden",
|
"NotFound_post:postUpdate": "Post nicht gefunden",
|
||||||
|
|
||||||
|
"NotAllowed_delete|PermissionsUser": "Keine Berechtigung",
|
||||||
|
"NotFound_user:delete|PermissionsUser": "Benutzer nicht gefunden",
|
||||||
|
|
||||||
"Duplicate_user:register": "Ein Benutzer mit diesem Benutzernamen oder E-Mail existiert schon",
|
"Duplicate_user:register": "Ein Benutzer mit diesem Benutzernamen oder E-Mail existiert schon",
|
||||||
|
|
||||||
"username": "Benutzername",
|
"username": "Benutzername",
|
||||||
@@ -97,5 +100,17 @@
|
|||||||
"Session expired": "Deine Sitzung ist abgelaufen.",
|
"Session expired": "Deine Sitzung ist abgelaufen.",
|
||||||
"General error": "Da ist wohl was schief gelaufen.",
|
"General error": "Da ist wohl was schief gelaufen.",
|
||||||
|
|
||||||
"Favicon": "Gästebuch Icons erstellt von Smashicons - Flaticon"
|
"Favicon": "Gästebuch Icons erstellt von Smashicons - Flaticon",
|
||||||
|
|
||||||
|
"Password confirm": "Passwort bestätigen",
|
||||||
|
"Password match": "Passwörter stimmen nicht überein",
|
||||||
|
"Change password": "Passwort ändern",
|
||||||
|
|
||||||
|
"Confirm user delete title": "Diesen User löschen?",
|
||||||
|
"Confirm user delete body": "Möchtest du {{name}} wirklich Löschen?",
|
||||||
|
|
||||||
|
"Manage users": "Benutzer verwalten",
|
||||||
|
"Admin": "Administration",
|
||||||
|
"Make Admin": "Administratorrecht erteilen",
|
||||||
|
"Demote Admin": "Administratorrecht entziehen"
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-1
@@ -20,6 +20,9 @@
|
|||||||
"NotAllowed_postUpdate": "Not allowed",
|
"NotAllowed_postUpdate": "Not allowed",
|
||||||
"NotFound_post:postUpdate": "Post not found",
|
"NotFound_post:postUpdate": "Post not found",
|
||||||
|
|
||||||
|
"NotAllowed_delete|PermissionsUser": "Not allowed",
|
||||||
|
"NotFound_user:deleteUserdelete|PermissionsUser": "User not found",
|
||||||
|
|
||||||
"Duplicate_user:register": "A user with this username or email already exists",
|
"Duplicate_user:register": "A user with this username or email already exists",
|
||||||
|
|
||||||
"username": "username",
|
"username": "username",
|
||||||
@@ -98,5 +101,17 @@
|
|||||||
"Session expired": "Your session has expired.",
|
"Session expired": "Your session has expired.",
|
||||||
"General error": "Looks like something went wrong.",
|
"General error": "Looks like something went wrong.",
|
||||||
|
|
||||||
"Favicon": "Guests book icons created by Smashicons - Flaticon"
|
"Favicon": "Guests book icons created by Smashicons - Flaticon",
|
||||||
|
|
||||||
|
"Password confirm": "Confirm password",
|
||||||
|
"Password match": "Password do not match",
|
||||||
|
"Change password": "Change password",
|
||||||
|
|
||||||
|
"Confirm user delete title": "Diesen User löschen?",
|
||||||
|
"Confirm user delete body": "Möchtest du {{name}} wirklich Löschen?",
|
||||||
|
|
||||||
|
"Manage users": "Manage users",
|
||||||
|
"Admin": "Administration",
|
||||||
|
"Make Admin": "Make Admin",
|
||||||
|
"Demote Admin": "Demote Admin"
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "react",
|
"name": "react",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "3.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -20,6 +20,9 @@
|
|||||||
"NotAllowed_postUpdate": "Keine Berechtigung",
|
"NotAllowed_postUpdate": "Keine Berechtigung",
|
||||||
"NotFound_post:postUpdate": "Post nicht gefunden",
|
"NotFound_post:postUpdate": "Post nicht gefunden",
|
||||||
|
|
||||||
|
"NotAllowed_delete|PermissionsUser": "Keine Berechtigung",
|
||||||
|
"NotFound_user:delete|PermissionsUser": "Benutzer nicht gefunden",
|
||||||
|
|
||||||
"Duplicate_user:register": "Ein Benutzer mit diesem Benutzernamen oder E-Mail existiert schon",
|
"Duplicate_user:register": "Ein Benutzer mit diesem Benutzernamen oder E-Mail existiert schon",
|
||||||
|
|
||||||
"username": "Benutzername",
|
"username": "Benutzername",
|
||||||
@@ -97,5 +100,17 @@
|
|||||||
"Session expired": "Deine Sitzung ist abgelaufen.",
|
"Session expired": "Deine Sitzung ist abgelaufen.",
|
||||||
"General error": "Da ist wohl was schief gelaufen.",
|
"General error": "Da ist wohl was schief gelaufen.",
|
||||||
|
|
||||||
"Favicon": "Gästebuch Icons erstellt von Smashicons - Flaticon"
|
"Favicon": "Gästebuch Icons erstellt von Smashicons - Flaticon",
|
||||||
|
|
||||||
|
"Password confirm": "Passwort bestätigen",
|
||||||
|
"Password match": "Passwörter stimmen nicht überein",
|
||||||
|
"Change password": "Passwort ändern",
|
||||||
|
|
||||||
|
"Confirm user delete title": "Diesen User löschen?",
|
||||||
|
"Confirm user delete body": "Möchtest du {{name}} wirklich Löschen?",
|
||||||
|
|
||||||
|
"Manage users": "Benutzer verwalten",
|
||||||
|
"Admin": "Administration",
|
||||||
|
"Make Admin": "Administratorrecht erteilen",
|
||||||
|
"Demote Admin": "Administratorrecht entziehen"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@
|
|||||||
"NotAllowed_postUpdate": "Not allowed",
|
"NotAllowed_postUpdate": "Not allowed",
|
||||||
"NotFound_post:postUpdate": "Post not found",
|
"NotFound_post:postUpdate": "Post not found",
|
||||||
|
|
||||||
|
"NotAllowed_delete|PermissionsUser": "Not allowed",
|
||||||
|
"NotFound_user:deleteUserdelete|PermissionsUser": "User not found",
|
||||||
|
|
||||||
"Duplicate_user:register": "A user with this username or email already exists",
|
"Duplicate_user:register": "A user with this username or email already exists",
|
||||||
|
|
||||||
"username": "username",
|
"username": "username",
|
||||||
@@ -98,5 +101,17 @@
|
|||||||
"Session expired": "Your session has expired.",
|
"Session expired": "Your session has expired.",
|
||||||
"General error": "Looks like something went wrong.",
|
"General error": "Looks like something went wrong.",
|
||||||
|
|
||||||
"Favicon": "Guests book icons created by Smashicons - Flaticon"
|
"Favicon": "Guests book icons created by Smashicons - Flaticon",
|
||||||
|
|
||||||
|
"Password confirm": "Confirm password",
|
||||||
|
"Password match": "Password do not match",
|
||||||
|
"Change password": "Change password",
|
||||||
|
|
||||||
|
"Confirm user delete title": "Diesen User löschen?",
|
||||||
|
"Confirm user delete body": "Möchtest du {{name}} wirklich Löschen?",
|
||||||
|
|
||||||
|
"Manage users": "Manage users",
|
||||||
|
"Admin": "Administration",
|
||||||
|
"Make Admin": "Make Admin",
|
||||||
|
"Demote Admin": "Demote Admin"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import { createContext, FC, PropsWithChildren, useContext, useEffect, useRef, useState } from 'react';
|
import { createContext, FC, PropsWithChildren, useContext, useEffect, useRef, useState } from 'react';
|
||||||
|
import { ERRORS } from '../components/Error/Errors';
|
||||||
import { POST_LIMIT, PROFILE_POST_LIMIT } from '../constanst';
|
import { POST_LIMIT, PROFILE_POST_LIMIT } from '../constanst';
|
||||||
import useGuestBookStore from '../store/store';
|
import useGuestBookStore from '../store/store';
|
||||||
import { PostAuth, PostCreate, PostDelete, PostListAuth, PostListNonAuth, PostNew, PostUpdate } from '../types/Post';
|
import { PostAuth, PostCreate, PostDelete, PostListAuth, PostListNonAuth, PostNew, PostUpdate } from '../types/Post';
|
||||||
import { User, UserCreate, UserImageUpdate, UserUpdate } from '../types/User';
|
import {
|
||||||
|
User,
|
||||||
|
UserCreate,
|
||||||
|
UserDelete,
|
||||||
|
UserImageUpdate,
|
||||||
|
UserList,
|
||||||
|
UserPermissionsUpdate,
|
||||||
|
UserUpdate,
|
||||||
|
} from '../types/User';
|
||||||
|
|
||||||
const BASE = 'https://khofmann.userpage.fu-berlin.de/phpCourse/exam/api/';
|
const BASE = 'https://khofmann.userpage.fu-berlin.de/phpCourse/exam/api/';
|
||||||
|
|
||||||
@@ -19,11 +28,14 @@ interface ApiContext {
|
|||||||
updatePost?: (data: PostUpdate, id: number) => Promise<PostAuth>;
|
updatePost?: (data: PostUpdate, id: number) => Promise<PostAuth>;
|
||||||
deletePost?: (id: number) => Promise<PostDelete>;
|
deletePost?: (id: number) => Promise<PostDelete>;
|
||||||
|
|
||||||
|
users?: (page?: number) => Promise<UserList>;
|
||||||
user?: (id?: number) => Promise<User>;
|
user?: (id?: number) => Promise<User>;
|
||||||
createUser?: (data: UserCreate) => Promise<User>;
|
createUser?: (data: UserCreate) => Promise<User>;
|
||||||
confirmUser?: (code: string) => Promise<User>;
|
confirmUser?: (code: string) => Promise<User>;
|
||||||
updateUser?: (data: UserUpdate, id?: number) => Promise<User>;
|
updateUser?: (data: UserUpdate, id?: number) => Promise<User>;
|
||||||
updateUserImage?: (data: UserImageUpdate, id?: number) => Promise<User>;
|
updateUserImage?: (data: UserImageUpdate, id?: number) => Promise<User>;
|
||||||
|
updateUserPermissions?: (data: UserPermissionsUpdate, id: number) => Promise<User>;
|
||||||
|
deleteUser?: (id: number) => Promise<UserDelete>;
|
||||||
|
|
||||||
userPosts?: (id?: number) => Promise<PostListAuth>;
|
userPosts?: (id?: number) => Promise<PostListAuth>;
|
||||||
}
|
}
|
||||||
@@ -47,11 +59,14 @@ export const useApi = () => {
|
|||||||
updatePost,
|
updatePost,
|
||||||
deletePost,
|
deletePost,
|
||||||
|
|
||||||
|
users,
|
||||||
user,
|
user,
|
||||||
createUser,
|
createUser,
|
||||||
confirmUser,
|
confirmUser,
|
||||||
updateUser,
|
updateUser,
|
||||||
updateUserImage,
|
updateUserImage,
|
||||||
|
updateUserPermissions,
|
||||||
|
deleteUser,
|
||||||
|
|
||||||
userPosts,
|
userPosts,
|
||||||
} = useContext(ApiContext);
|
} = useContext(ApiContext);
|
||||||
@@ -63,11 +78,14 @@ export const useApi = () => {
|
|||||||
newPost &&
|
newPost &&
|
||||||
updatePost &&
|
updatePost &&
|
||||||
deletePost &&
|
deletePost &&
|
||||||
|
users &&
|
||||||
user &&
|
user &&
|
||||||
createUser &&
|
createUser &&
|
||||||
confirmUser &&
|
confirmUser &&
|
||||||
updateUser &&
|
updateUser &&
|
||||||
updateUserImage &&
|
updateUserImage &&
|
||||||
|
updateUserPermissions &&
|
||||||
|
deleteUser &&
|
||||||
userPosts
|
userPosts
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
@@ -82,11 +100,14 @@ export const useApi = () => {
|
|||||||
updatePost,
|
updatePost,
|
||||||
deletePost,
|
deletePost,
|
||||||
|
|
||||||
|
users,
|
||||||
user,
|
user,
|
||||||
createUser,
|
createUser,
|
||||||
confirmUser,
|
confirmUser,
|
||||||
updateUser,
|
updateUser,
|
||||||
updateUserImage,
|
updateUserImage,
|
||||||
|
updateUserPermissions,
|
||||||
|
deleteUser,
|
||||||
|
|
||||||
userPosts,
|
userPosts,
|
||||||
};
|
};
|
||||||
@@ -157,6 +178,12 @@ export const ApiProvider: FC<PropsWithChildren<Record<string, unknown>>> = ({ ch
|
|||||||
return await (await reAuth(() => _delete(`posts/${id}?l=${POST_LIMIT}`))).json();
|
return await (await reAuth(() => _delete(`posts/${id}?l=${POST_LIMIT}`))).json();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const users = async (page?: number): Promise<UserList> => {
|
||||||
|
const url = `users?p=${page ?? 0}&l=${POST_LIMIT}`;
|
||||||
|
|
||||||
|
return await (await reAuth(() => getAuth(url))).json();
|
||||||
|
};
|
||||||
|
|
||||||
const user = async (id?: number): Promise<User> => {
|
const user = async (id?: number): Promise<User> => {
|
||||||
return await (await reAuth(() => getAuth(`users/${id ?? authenticatedUser?.id}`))).json();
|
return await (await reAuth(() => getAuth(`users/${id ?? authenticatedUser?.id}`))).json();
|
||||||
};
|
};
|
||||||
@@ -187,10 +214,21 @@ export const ApiProvider: FC<PropsWithChildren<Record<string, unknown>>> = ({ ch
|
|||||||
return _user;
|
return _user;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateUserPermissions = async (data: UserPermissionsUpdate, id: number): Promise<User> => {
|
||||||
|
const _user = await (
|
||||||
|
await reAuth(() => patchAuth(`users/${id}/permissions`, data as Record<string, unknown>))
|
||||||
|
).json();
|
||||||
|
return _user;
|
||||||
|
};
|
||||||
|
|
||||||
const userPosts = async (id?: number): Promise<PostListAuth> => {
|
const userPosts = async (id?: number): Promise<PostListAuth> => {
|
||||||
return await (await reAuth(() => getAuth(`users/${id}/posts?l=${PROFILE_POST_LIMIT}&s=desc`))).json();
|
return await (await reAuth(() => getAuth(`users/${id}/posts?l=${PROFILE_POST_LIMIT}&s=desc`))).json();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteUser = async (id: number): Promise<UserDelete> => {
|
||||||
|
return await (await reAuth(() => _delete(`users/${id}?l=${POST_LIMIT}`))).json();
|
||||||
|
};
|
||||||
|
|
||||||
/* IMPL */
|
/* IMPL */
|
||||||
|
|
||||||
const post = async (endpoint: string, body?: Record<string, unknown>, headers?: HeadersInit) => {
|
const post = async (endpoint: string, body?: Record<string, unknown>, headers?: HeadersInit) => {
|
||||||
@@ -284,9 +322,13 @@ export const ApiProvider: FC<PropsWithChildren<Record<string, unknown>>> = ({ ch
|
|||||||
const ret = await callback();
|
const ret = await callback();
|
||||||
|
|
||||||
return ret;
|
return ret;
|
||||||
} catch {
|
//eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} catch (error: any) {
|
||||||
|
console.log('[REAUTH] failed once', error);
|
||||||
|
if (error.code !== ERRORS.UNAUTHORIZED) throw error;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[REAUTH] fail, refreshing');
|
console.log('[REAUTH] failed due to authentication, try refreshing session');
|
||||||
// REAUTH
|
// REAUTH
|
||||||
await refresh();
|
await refresh();
|
||||||
// DO AGAIN
|
// DO AGAIN
|
||||||
@@ -294,13 +336,13 @@ export const ApiProvider: FC<PropsWithChildren<Record<string, unknown>>> = ({ ch
|
|||||||
const ret = await callback();
|
const ret = await callback();
|
||||||
|
|
||||||
return ret;
|
return ret;
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
console.log('[REAUTH] terminating session', error);
|
console.log('[REAUTH] terminating session', _error);
|
||||||
setAuthenticatedUser(undefined);
|
setAuthenticatedUser(undefined);
|
||||||
setHasAuth(false);
|
setHasAuth(false);
|
||||||
setCurrentSession([undefined, undefined]);
|
setCurrentSession([undefined, undefined]);
|
||||||
token.current = undefined;
|
token.current = undefined;
|
||||||
throw error;
|
throw _error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -332,11 +374,14 @@ export const ApiProvider: FC<PropsWithChildren<Record<string, unknown>>> = ({ ch
|
|||||||
updatePost,
|
updatePost,
|
||||||
deletePost,
|
deletePost,
|
||||||
|
|
||||||
|
users,
|
||||||
user,
|
user,
|
||||||
createUser,
|
createUser,
|
||||||
confirmUser,
|
confirmUser,
|
||||||
updateUser,
|
updateUser,
|
||||||
updateUserImage,
|
updateUserImage,
|
||||||
|
updateUserPermissions,
|
||||||
|
deleteUser,
|
||||||
|
|
||||||
userPosts,
|
userPosts,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
import { useForm } from '@tanstack/react-form';
|
import { useForm } from '@tanstack/react-form';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { t } from 'i18next';
|
import { t } from 'i18next';
|
||||||
import { FC, FormEvent, useState } from 'react';
|
import { FC, FormEvent, useEffect, useState } from 'react';
|
||||||
import { useApi } from '../../../api/Api';
|
import { useApi } from '../../../api/Api';
|
||||||
import { PostAuth, PostUpdate } from '../../../types/Post';
|
import { PostAuth, PostUpdate } from '../../../types/Post';
|
||||||
import ErrorComponent from '../../Error/ErrorComponent';
|
import ErrorComponent from '../../Error/ErrorComponent';
|
||||||
@@ -35,9 +35,7 @@ const PostEditDialog: FC<Props> = ({ post, open, onClose }) => {
|
|||||||
const Api = useApi();
|
const Api = useApi();
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: ({ data, id }: { data: PostUpdate; id: number }) => {
|
mutationFn: ({ data, id }: { data: PostUpdate; id: number }) => Api.updatePost(data, id),
|
||||||
return Api.updatePost(data, id);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm<PostUpdate>({
|
const form = useForm<PostUpdate>({
|
||||||
@@ -71,6 +69,10 @@ const PostEditDialog: FC<Props> = ({ post, open, onClose }) => {
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Api.hasAuth) handleClose();
|
||||||
|
}, [Api.hasAuth]); //eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
|
|||||||
@@ -35,21 +35,26 @@ const RegisterDialog: FC<Props> = ({ open, onClose }) => {
|
|||||||
const Api = useApi();
|
const Api = useApi();
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: ({ data }: { data: UserCreate }) => {
|
mutationFn: ({ data }: { data: UserCreate }) => Api.createUser(data),
|
||||||
return Api.createUser(data);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm<UserCreate>({
|
const form = useForm<UserCreate & { passwordConfirm: string }>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
username: '',
|
username: '',
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
passwordConfirm: '',
|
||||||
},
|
},
|
||||||
onSubmit: async ({ value }) => {
|
onSubmit: async ({ value }) => {
|
||||||
try {
|
try {
|
||||||
createMutation.mutate(
|
createMutation.mutate(
|
||||||
{ data: value },
|
{
|
||||||
|
data: {
|
||||||
|
username: value.username,
|
||||||
|
email: value.email,
|
||||||
|
password: value.password,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => setError(undefined),
|
onSuccess: () => setError(undefined),
|
||||||
onError: setError,
|
onError: setError,
|
||||||
@@ -113,7 +118,7 @@ const RegisterDialog: FC<Props> = ({ open, onClose }) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogTitle>{t('Register')}</DialogTitle>
|
<DialogTitle>{t('Register')}</DialogTitle>
|
||||||
<DialogContent sx={{ gap: 2 }}>
|
<DialogContent>
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<form.Field
|
<form.Field
|
||||||
@@ -215,6 +220,48 @@ const RegisterDialog: FC<Props> = ({ open, onClose }) => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<form.Field
|
||||||
|
name="passwordConfirm"
|
||||||
|
validators={{
|
||||||
|
onChangeListenTo: ['password'],
|
||||||
|
onChange: ({ value, fieldApi }) =>
|
||||||
|
!value
|
||||||
|
? t('Password required')
|
||||||
|
: value !== fieldApi.form.getFieldValue('password')
|
||||||
|
? t('Password match')
|
||||||
|
: undefined,
|
||||||
|
onChangeAsyncDebounceMs: 250,
|
||||||
|
onChangeAsync: async ({ value, fieldApi }) =>
|
||||||
|
!value
|
||||||
|
? t('Password required')
|
||||||
|
: value !== fieldApi.form.getFieldValue('password')
|
||||||
|
? t('Password match')
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
children={(field) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
variant="outlined"
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
label={t('Password confirm')}
|
||||||
|
required
|
||||||
|
error={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||||
|
helperText={field.state.meta.isTouched ? field.state.meta.errors.join(',') : ''}
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<form.Subscribe
|
<form.Subscribe
|
||||||
selector={(state) => [state.canSubmit, state.isSubmitting]}
|
selector={(state) => [state.canSubmit, state.isSubmitting]}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
DialogActions,
|
DialogActions,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
Grid,
|
||||||
TextField,
|
TextField,
|
||||||
useMediaQuery,
|
useMediaQuery,
|
||||||
useTheme,
|
useTheme,
|
||||||
@@ -12,7 +13,7 @@ import {
|
|||||||
import { useForm } from '@tanstack/react-form';
|
import { useForm } from '@tanstack/react-form';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { t } from 'i18next';
|
import { t } from 'i18next';
|
||||||
import { FC, FormEvent, useState } from 'react';
|
import { FC, FormEvent, useEffect, useState } from 'react';
|
||||||
import { useApi } from '../../../api/Api';
|
import { useApi } from '../../../api/Api';
|
||||||
import { User, UserUpdate } from '../../../types/User';
|
import { User, UserUpdate } from '../../../types/User';
|
||||||
import ErrorComponent from '../../Error/ErrorComponent';
|
import ErrorComponent from '../../Error/ErrorComponent';
|
||||||
@@ -33,9 +34,7 @@ const UserEditDialog: FC<Props> = ({ user, open, onClose }) => {
|
|||||||
const Api = useApi();
|
const Api = useApi();
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: ({ data, id }: { data: UserUpdate; id?: number }) => {
|
mutationFn: ({ data, id }: { data: UserUpdate; id?: number }) => Api.updateUser(data, id),
|
||||||
return Api.updateUser(data, id);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm<UserUpdate>({
|
const form = useForm<UserUpdate>({
|
||||||
@@ -71,6 +70,10 @@ const UserEditDialog: FC<Props> = ({ user, open, onClose }) => {
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Api.hasAuth) handleClose();
|
||||||
|
}, [Api.hasAuth]); //eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
@@ -94,66 +97,72 @@ const UserEditDialog: FC<Props> = ({ user, open, onClose }) => {
|
|||||||
>
|
>
|
||||||
<DialogTitle>{t('Edit data')}</DialogTitle>
|
<DialogTitle>{t('Edit data')}</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<form.Field
|
<Grid container spacing={2}>
|
||||||
name="username"
|
<Grid item xs={12}>
|
||||||
validators={{
|
<form.Field
|
||||||
onChange: ({ value }) => (!value ? t('Username required') : undefined),
|
name="username"
|
||||||
onChangeAsyncDebounceMs: 250,
|
validators={{
|
||||||
onChangeAsync: async ({ value }) => {
|
onChange: ({ value }) => (!value ? t('Username required') : undefined),
|
||||||
return !value && t('Username required');
|
onChangeAsyncDebounceMs: 250,
|
||||||
},
|
onChangeAsync: async ({ value }) => {
|
||||||
}}
|
return !value && t('Username required');
|
||||||
children={(field) => {
|
},
|
||||||
return (
|
}}
|
||||||
<>
|
children={(field) => {
|
||||||
<TextField
|
return (
|
||||||
name={field.name}
|
<>
|
||||||
value={field.state.value}
|
<TextField
|
||||||
onBlur={field.handleBlur}
|
name={field.name}
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
value={field.state.value}
|
||||||
size="small"
|
onBlur={field.handleBlur}
|
||||||
label={t('Username')}
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
required
|
size="small"
|
||||||
margin="dense"
|
label={t('Username')}
|
||||||
autoComplete="new-username"
|
required
|
||||||
fullWidth
|
margin="dense"
|
||||||
error={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
autoComplete="new-username"
|
||||||
helperText={field.state.meta.isTouched ? field.state.meta.errors.join(',') : ''}
|
fullWidth
|
||||||
/>
|
error={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||||
</>
|
helperText={field.state.meta.isTouched ? field.state.meta.errors.join(',') : ''}
|
||||||
);
|
/>
|
||||||
}}
|
</>
|
||||||
/>
|
);
|
||||||
<form.Field
|
}}
|
||||||
name="email"
|
/>
|
||||||
validators={{
|
</Grid>
|
||||||
onChange: ({ value }) => (!value ? t('Email required') : undefined),
|
<Grid item xs={12}>
|
||||||
onChangeAsyncDebounceMs: 250,
|
<form.Field
|
||||||
onChangeAsync: async ({ value }) => {
|
name="email"
|
||||||
return !value && t('Email required');
|
validators={{
|
||||||
},
|
onChange: ({ value }) => (!value ? t('Email required') : undefined),
|
||||||
}}
|
onChangeAsyncDebounceMs: 250,
|
||||||
children={(field) => {
|
onChangeAsync: async ({ value }) => {
|
||||||
return (
|
return !value && t('Email required');
|
||||||
<>
|
},
|
||||||
<TextField
|
}}
|
||||||
name={field.name}
|
children={(field) => {
|
||||||
value={field.state.value}
|
return (
|
||||||
onBlur={field.handleBlur}
|
<>
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
<TextField
|
||||||
size="small"
|
name={field.name}
|
||||||
label={t('Email')}
|
value={field.state.value}
|
||||||
required
|
onBlur={field.handleBlur}
|
||||||
margin="dense"
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
autoComplete="new-email"
|
size="small"
|
||||||
fullWidth
|
label={t('Email')}
|
||||||
error={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
required
|
||||||
helperText={field.state.meta.isTouched ? field.state.meta.errors.join(',') : ''}
|
margin="dense"
|
||||||
/>
|
autoComplete="new-email"
|
||||||
</>
|
fullWidth
|
||||||
);
|
error={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||||
}}
|
helperText={field.state.meta.isTouched ? field.state.meta.errors.join(',') : ''}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<form.Subscribe
|
<form.Subscribe
|
||||||
|
|||||||
+6
-4
@@ -23,7 +23,7 @@ import {
|
|||||||
import { useForm } from '@tanstack/react-form';
|
import { useForm } from '@tanstack/react-form';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { t } from 'i18next';
|
import { t } from 'i18next';
|
||||||
import { FC, FormEvent, useState } from 'react';
|
import { FC, FormEvent, useEffect, useState } from 'react';
|
||||||
import { useApi } from '../../../api/Api';
|
import { useApi } from '../../../api/Api';
|
||||||
import { User, UserImageUpdate } from '../../../types/User';
|
import { User, UserImageUpdate } from '../../../types/User';
|
||||||
import ErrorComponent from '../../Error/ErrorComponent';
|
import ErrorComponent from '../../Error/ErrorComponent';
|
||||||
@@ -44,9 +44,7 @@ const UserImageDialog: FC<Props> = ({ user, open, onClose }) => {
|
|||||||
const Api = useApi();
|
const Api = useApi();
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: ({ data, id }: { data: UserImageUpdate; id?: number }) => {
|
mutationFn: ({ data, id }: { data: UserImageUpdate; id?: number }) => Api.updateUserImage(data, id),
|
||||||
return Api.updateUserImage(data, id);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm<UserImageUpdate>({
|
const form = useForm<UserImageUpdate>({
|
||||||
@@ -79,6 +77,10 @@ const UserImageDialog: FC<Props> = ({ user, open, onClose }) => {
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Api.hasAuth) handleClose();
|
||||||
|
}, [Api.hasAuth]); //eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
Grid,
|
||||||
|
TextField,
|
||||||
|
useMediaQuery,
|
||||||
|
useTheme,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useForm } from '@tanstack/react-form';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { t } from 'i18next';
|
||||||
|
import { FC, FormEvent, useEffect, useState } from 'react';
|
||||||
|
import { useApi } from '../../../api/Api';
|
||||||
|
import { User, UserUpdate } from '../../../types/User';
|
||||||
|
import ErrorComponent from '../../Error/ErrorComponent';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserPasswordDialog: FC<Props> = ({ user, open, onClose }) => {
|
||||||
|
//eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const [error, setError] = useState<any>();
|
||||||
|
|
||||||
|
const theme = useTheme();
|
||||||
|
const fullScreen = useMediaQuery(theme.breakpoints.only('xs'), { noSsr: true });
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const Api = useApi();
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ data, id }: { data: UserUpdate; id?: number }) => Api.updateUser(data, id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<UserUpdate & { passwordConfirm: string }>({
|
||||||
|
defaultValues: {
|
||||||
|
password: '',
|
||||||
|
passwordConfirm: '',
|
||||||
|
},
|
||||||
|
onSubmit: async ({ value }) => {
|
||||||
|
try {
|
||||||
|
updateMutation.mutate(
|
||||||
|
{ data: { password: value.password }, id: Api.authenticatedUser?.id === user.id ? undefined : user.id },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
handleClose();
|
||||||
|
|
||||||
|
const queryKey = Api.authenticatedUser?.id === user.id ? ['profile'] : ['profile', { id: user.id }];
|
||||||
|
queryClient.invalidateQueries({ queryKey });
|
||||||
|
},
|
||||||
|
onError: setError,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
//eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} catch (_error: any) {
|
||||||
|
setError(_error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
form.reset();
|
||||||
|
setError(undefined);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Api.hasAuth) handleClose();
|
||||||
|
}, [Api.hasAuth]); //eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
fullWidth
|
||||||
|
fullScreen={fullScreen}
|
||||||
|
PaperProps={{
|
||||||
|
component: 'form',
|
||||||
|
onSubmit: (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
form.handleSubmit();
|
||||||
|
},
|
||||||
|
onKeyDown: (event: React.KeyboardEvent<HTMLFormElement>) => {
|
||||||
|
if (event.key === 'Tab') {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
noValidate: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle>{t('Edit data')}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<form.Field
|
||||||
|
name="password"
|
||||||
|
validators={{
|
||||||
|
onChange: ({ value }) => (!value ? t('Password required') : undefined),
|
||||||
|
onChangeAsyncDebounceMs: 250,
|
||||||
|
onChangeAsync: async ({ value }) => {
|
||||||
|
return !value && t('Password required');
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
children={(field) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
variant="outlined"
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
label={t('Password')}
|
||||||
|
required
|
||||||
|
error={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||||
|
helperText={field.state.meta.isTouched ? field.state.meta.errors.join(',') : ''}
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<form.Field
|
||||||
|
name="passwordConfirm"
|
||||||
|
validators={{
|
||||||
|
onChangeListenTo: ['password'],
|
||||||
|
onChange: ({ value, fieldApi }) =>
|
||||||
|
!value
|
||||||
|
? t('Password required')
|
||||||
|
: value !== fieldApi.form.getFieldValue('password')
|
||||||
|
? t('Password match')
|
||||||
|
: undefined,
|
||||||
|
onChangeAsyncDebounceMs: 250,
|
||||||
|
onChangeAsync: async ({ value, fieldApi }) =>
|
||||||
|
!value
|
||||||
|
? t('Password required')
|
||||||
|
: value !== fieldApi.form.getFieldValue('password')
|
||||||
|
? t('Password match')
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
children={(field) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
variant="outlined"
|
||||||
|
name={field.name}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
label={t('Password confirm')}
|
||||||
|
required
|
||||||
|
error={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||||
|
helperText={field.state.meta.isTouched ? field.state.meta.errors.join(',') : ''}
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<form.Subscribe
|
||||||
|
selector={(state) => [state.canSubmit, state.isSubmitting]}
|
||||||
|
children={([canSubmit]) => (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => {
|
||||||
|
handleClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSubmit || updateMutation.isPending}
|
||||||
|
autoFocus
|
||||||
|
variant="contained"
|
||||||
|
endIcon={updateMutation.isPending && <CircularProgress color="inherit" size="20px" />}
|
||||||
|
>
|
||||||
|
{t('Save')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</DialogActions>
|
||||||
|
{error && (
|
||||||
|
<DialogContent>
|
||||||
|
<ErrorComponent error={error} context="userUpdate" />
|
||||||
|
</DialogContent>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserPasswordDialog;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ErrorOutline } from '@mui/icons-material';
|
import { ErrorOutline } from '@mui/icons-material';
|
||||||
import { Grid, Link as MUILink, Typography } from '@mui/material';
|
import { Grid, Link as MUILink, Typography } from '@mui/material';
|
||||||
import { useQueryErrorResetBoundary } from '@tanstack/react-query';
|
import { useQueryClient, useQueryErrorResetBoundary } from '@tanstack/react-query';
|
||||||
import { ErrorRouteComponent as TSErrorRouteComponent, useNavigate, useRouter } from '@tanstack/react-router';
|
import { ErrorRouteComponent as TSErrorRouteComponent, useNavigate, useRouter } from '@tanstack/react-router';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -13,10 +13,11 @@ const ErrorRouterComponent: TSErrorRouteComponent = ({ error }) => {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryErrorResetBoundary = useQueryErrorResetBoundary();
|
const queryErrorResetBoundary = useQueryErrorResetBoundary();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Reset the query error boundary
|
// Reset the query error boundary
|
||||||
console.log(queryErrorResetBoundary.isReset());
|
console.log(queryErrorResetBoundary);
|
||||||
queryErrorResetBoundary.reset();
|
queryErrorResetBoundary.reset();
|
||||||
}, [queryErrorResetBoundary]);
|
}, [queryErrorResetBoundary]);
|
||||||
|
|
||||||
@@ -34,9 +35,9 @@ const ErrorRouterComponent: TSErrorRouteComponent = ({ error }) => {
|
|||||||
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center' }}>
|
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
<MUILink
|
<MUILink
|
||||||
variant="h6"
|
variant="h6"
|
||||||
underline="none"
|
sx={{ cursor: 'pointer' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
console.log('CLICK AS WELL');
|
queryClient.clear();
|
||||||
router.invalidate();
|
router.invalidate();
|
||||||
navigate({ to: ROUTES.INDEX });
|
navigate({ to: ROUTES.INDEX });
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const Footer: FC = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center' }}>
|
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
<Typography variant="caption">© 2024 Kilian Kurt Hofmann</Typography>
|
<Typography variant="caption">© 2024 Kilian Kurt Hofmann | Build {__APP_VERSION__}</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -19,9 +19,7 @@ const PostForm: FC = () => {
|
|||||||
const Api = useApi();
|
const Api = useApi();
|
||||||
|
|
||||||
const newMutation = useMutation({
|
const newMutation = useMutation({
|
||||||
mutationFn: ({ data }: { data: PostCreate }) => {
|
mutationFn: ({ data }: { data: PostCreate }) => Api.newPost(data),
|
||||||
return Api.newPost(data);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm<PostCreate>({
|
const form = useForm<PostCreate>({
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { Link, useRouterState } from '@tanstack/react-router';
|
import { Link, useRouterState } from '@tanstack/react-router';
|
||||||
import { cloneElement, FC, ReactElement, useState } from 'react';
|
import { cloneElement, FC, ReactElement, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import GuestBook from '../../../assets/img/guests-book.png';
|
||||||
import { useApi } from '../../api/Api';
|
import { useApi } from '../../api/Api';
|
||||||
import useGuestBookStore from '../../store/store';
|
import useGuestBookStore from '../../store/store';
|
||||||
import LanguageMenu from '../Menus/Language/LanguageMenu';
|
import LanguageMenu from '../Menus/Language/LanguageMenu';
|
||||||
@@ -51,8 +52,15 @@ const Header: FC = () => {
|
|||||||
<AppBar>
|
<AppBar>
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<Box sx={{ flexGrow: 1, alignItems: 'center', display: 'flex', gap: 1 }}>
|
<Box sx={{ flexGrow: 1, alignItems: 'center', display: 'flex', gap: 1 }}>
|
||||||
<MUILink component={Link} to="/" color="white" variant="h6" underline="none">
|
<MUILink
|
||||||
{t('GuestBook')}
|
component={Link}
|
||||||
|
to="/"
|
||||||
|
color="white"
|
||||||
|
variant="h6"
|
||||||
|
underline="none"
|
||||||
|
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
|
||||||
|
>
|
||||||
|
<img src={GuestBook} width="24px" height="24px" alt={t('GuestBook')} /> {t('GuestBook')}
|
||||||
</MUILink>
|
</MUILink>
|
||||||
{isLoading && <CircularProgress size={16} thickness={10} sx={{ color: 'white' }} />}
|
{isLoading && <CircularProgress size={16} thickness={10} sx={{ color: 'white' }} />}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Box, Link, Menu, MenuItem, Typography } from '@mui/material';
|
import { Box, Divider, Link, Menu, MenuItem, Typography } from '@mui/material';
|
||||||
import { useMatch, useNavigate, useRouter } from '@tanstack/react-router';
|
import { useMatch, useNavigate, useRouter } from '@tanstack/react-router';
|
||||||
import { t } from 'i18next';
|
import { t } from 'i18next';
|
||||||
import { FC, useState } from 'react';
|
import { FC, useState } from 'react';
|
||||||
@@ -69,6 +69,14 @@ const UserMenu: FC<Props> = ({ anchorEl, handleClose }) => {
|
|||||||
>
|
>
|
||||||
{t('Log out')}
|
{t('Log out')}
|
||||||
</MenuItem>,
|
</MenuItem>,
|
||||||
|
Api.authenticatedUser.isAdmin && [
|
||||||
|
<Divider>
|
||||||
|
<Typography variant="caption">{t('Admin')}</Typography>
|
||||||
|
</Divider>,
|
||||||
|
<MenuItem key="users" onClick={() => navigate({ to: ROUTES.USERS })}>
|
||||||
|
{t('Manage users')}
|
||||||
|
</MenuItem>,
|
||||||
|
],
|
||||||
]
|
]
|
||||||
) : register ? (
|
) : register ? (
|
||||||
<RegisterDialog open={register} onClose={() => setRegister(false)} />
|
<RegisterDialog open={register} onClose={() => setRegister(false)} />
|
||||||
@@ -81,7 +89,6 @@ const UserMenu: FC<Props> = ({ anchorEl, handleClose }) => {
|
|||||||
<Link
|
<Link
|
||||||
sx={{ cursor: 'pointer' }}
|
sx={{ cursor: 'pointer' }}
|
||||||
variant="body1"
|
variant="body1"
|
||||||
underline="hover"
|
|
||||||
color="secondary.main"
|
color="secondary.main"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setRegister(true);
|
setRegister(true);
|
||||||
|
|||||||
@@ -44,9 +44,7 @@ const Post: FC<Props> = ({ page = 0, post, disableActions }) => {
|
|||||||
const Api = useApi();
|
const Api = useApi();
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: number) => {
|
mutationFn: (id: number) => Api.deletePost(id),
|
||||||
return Api.deletePost(id);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import { PostAuth } from '../../types/Post';
|
|||||||
import { User } from '../../types/User';
|
import { User } from '../../types/User';
|
||||||
import convertDate from '../../utils/date';
|
import convertDate from '../../utils/date';
|
||||||
import UserEditDialog from '../Dialogs/UserEdit/UserEditDialog';
|
import UserEditDialog from '../Dialogs/UserEdit/UserEditDialog';
|
||||||
import UserImageDialog from '../Dialogs/UserImage/UserImageDialog';
|
import UserImageDialog from '../Dialogs/UserEdit/UserImageDialog';
|
||||||
|
import UserPasswordDialog from '../Dialogs/UserEdit/UserPasswordDialog';
|
||||||
import Post from '../Post/Post';
|
import Post from '../Post/Post';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -29,6 +30,7 @@ interface Props {
|
|||||||
const Profile: FC<Props> = ({ user, posts, canEdit }) => {
|
const Profile: FC<Props> = ({ user, posts, canEdit }) => {
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [imageOpen, setImageOpen] = useState(false);
|
const [imageOpen, setImageOpen] = useState(false);
|
||||||
|
const [passwordOpen, setPasswordOpen] = useState(false);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -39,11 +41,17 @@ const Profile: FC<Props> = ({ user, posts, canEdit }) => {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
<Grid item sx={{ display: 'flex', flexGrow: 1, justifyContent: 'center' }}>
|
<Grid item sx={{ display: 'flex', flexGrow: 1, justifyContent: 'center' }}>
|
||||||
<IconButton onClick={() => setImageOpen(true)}>
|
{canEdit ? (
|
||||||
|
<IconButton onClick={() => setImageOpen(true)}>
|
||||||
|
<Avatar alt={user.username} src={`${user.image}`} sx={{ width: '100px', height: '100px' }}>
|
||||||
|
<Person sx={{ width: '60px', height: '60px' }} />
|
||||||
|
</Avatar>
|
||||||
|
</IconButton>
|
||||||
|
) : (
|
||||||
<Avatar alt={user.username} src={`${user.image}`} sx={{ width: '100px', height: '100px' }}>
|
<Avatar alt={user.username} src={`${user.image}`} sx={{ width: '100px', height: '100px' }}>
|
||||||
<Person sx={{ width: '60px', height: '60px' }} />
|
<Person sx={{ width: '60px', height: '60px' }} />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</IconButton>
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item sx={{ display: 'flex', alignItems: 'center' }}>
|
<Grid item sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
<Box sx={{ display: 'grid', gridTemplateColumns: '120px 1fr', columnGap: 1 }}>
|
<Box sx={{ display: 'grid', gridTemplateColumns: '120px 1fr', columnGap: 1 }}>
|
||||||
@@ -65,8 +73,12 @@ const Profile: FC<Props> = ({ user, posts, canEdit }) => {
|
|||||||
<Button size="small" onClick={() => setEditOpen(true)}>
|
<Button size="small" onClick={() => setEditOpen(true)}>
|
||||||
{t('Edit')}
|
{t('Edit')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="small" color="secondary" onClick={() => setPasswordOpen(true)}>
|
||||||
|
{t('Change password')}
|
||||||
|
</Button>
|
||||||
<UserEditDialog user={user} open={editOpen} onClose={() => setEditOpen(false)} />
|
<UserEditDialog user={user} open={editOpen} onClose={() => setEditOpen(false)} />
|
||||||
<UserImageDialog user={user} open={imageOpen} onClose={() => setImageOpen(false)} />
|
<UserImageDialog user={user} open={imageOpen} onClose={() => setImageOpen(false)} />
|
||||||
|
<UserPasswordDialog user={user} open={passwordOpen} onClose={() => setPasswordOpen(false)} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CardActions>
|
</CardActions>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { queryOptions } from '@tanstack/react-query';
|
import { queryOptions } from '@tanstack/react-query';
|
||||||
import { useApi } from '../api/Api';
|
import { useApi } from '../api/Api';
|
||||||
|
import { ERRORS } from '../components/Error/Errors';
|
||||||
|
|
||||||
export const profileSelfQueryOptions = (Api: ReturnType<typeof useApi>) =>
|
export const profileSelfQueryOptions = (Api: ReturnType<typeof useApi>) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
@@ -8,10 +9,12 @@ export const profileSelfQueryOptions = (Api: ReturnType<typeof useApi>) =>
|
|||||||
user: await Api.user(),
|
user: await Api.user(),
|
||||||
posts: await Api.userPosts(Api.authenticatedUser?.id ?? 0),
|
posts: await Api.userPosts(Api.authenticatedUser?.id ?? 0),
|
||||||
}),
|
}),
|
||||||
|
retry: (count, error) => ('code' in error && error.code !== ERRORS.UNAUTHORIZED ? count < 3 : false),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const profileQueryOptions = (Api: ReturnType<typeof useApi>, id?: number) =>
|
export const profileQueryOptions = (Api: ReturnType<typeof useApi>, id?: number) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ['profile', { id }],
|
queryKey: ['profile', { id }],
|
||||||
queryFn: async () => ({ user: await Api.user(id), posts: await Api.userPosts(id) }),
|
queryFn: async () => ({ user: await Api.user(id), posts: await Api.userPosts(id) }),
|
||||||
|
retry: (count, error) => ('code' in error && error.code !== ERRORS.UNAUTHORIZED ? count < 3 : false),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { queryOptions } from '@tanstack/react-query';
|
||||||
|
import { useApi } from '../api/Api';
|
||||||
|
import { ERRORS } from '../components/Error/Errors';
|
||||||
|
|
||||||
|
export const usersQueryOptions = (Api: ReturnType<typeof useApi>, page?: number) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['users', { page: page ?? 0 }],
|
||||||
|
queryFn: async () => await Api.users(page),
|
||||||
|
retry: (count, error) => ('code' in error && error.code !== ERRORS.UNAUTHORIZED ? count < 3 : false),
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
import { Route as rootRoute } from './routes/__root'
|
import { Route as rootRoute } from './routes/__root'
|
||||||
import { Route as IndexImport } from './routes/index'
|
import { Route as IndexImport } from './routes/index'
|
||||||
|
import { Route as UsersIndexImport } from './routes/users/index'
|
||||||
import { Route as ProfileIndexImport } from './routes/profile/index'
|
import { Route as ProfileIndexImport } from './routes/profile/index'
|
||||||
import { Route as ConfirmIndexImport } from './routes/confirm/index'
|
import { Route as ConfirmIndexImport } from './routes/confirm/index'
|
||||||
import { Route as ProfileIdImport } from './routes/profile/$id'
|
import { Route as ProfileIdImport } from './routes/profile/$id'
|
||||||
@@ -23,6 +24,11 @@ const IndexRoute = IndexImport.update({
|
|||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
|
const UsersIndexRoute = UsersIndexImport.update({
|
||||||
|
path: '/users/',
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
const ProfileIndexRoute = ProfileIndexImport.update({
|
const ProfileIndexRoute = ProfileIndexImport.update({
|
||||||
path: '/profile/',
|
path: '/profile/',
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
@@ -70,6 +76,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ProfileIndexImport
|
preLoaderRoute: typeof ProfileIndexImport
|
||||||
parentRoute: typeof rootRoute
|
parentRoute: typeof rootRoute
|
||||||
}
|
}
|
||||||
|
'/users/': {
|
||||||
|
id: '/users/'
|
||||||
|
path: '/users'
|
||||||
|
fullPath: '/users'
|
||||||
|
preLoaderRoute: typeof UsersIndexImport
|
||||||
|
parentRoute: typeof rootRoute
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +93,7 @@ export const routeTree = rootRoute.addChildren({
|
|||||||
ProfileIdRoute,
|
ProfileIdRoute,
|
||||||
ConfirmIndexRoute,
|
ConfirmIndexRoute,
|
||||||
ProfileIndexRoute,
|
ProfileIndexRoute,
|
||||||
|
UsersIndexRoute,
|
||||||
})
|
})
|
||||||
|
|
||||||
/* prettier-ignore-end */
|
/* prettier-ignore-end */
|
||||||
@@ -93,7 +107,8 @@ export const routeTree = rootRoute.addChildren({
|
|||||||
"/",
|
"/",
|
||||||
"/profile/$id",
|
"/profile/$id",
|
||||||
"/confirm/",
|
"/confirm/",
|
||||||
"/profile/"
|
"/profile/",
|
||||||
|
"/users/"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"/": {
|
"/": {
|
||||||
@@ -107,6 +122,9 @@ export const routeTree = rootRoute.addChildren({
|
|||||||
},
|
},
|
||||||
"/profile/": {
|
"/profile/": {
|
||||||
"filePath": "profile/index.tsx"
|
"filePath": "profile/index.tsx"
|
||||||
|
},
|
||||||
|
"/users/": {
|
||||||
|
"filePath": "users/index.tsx"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { createRouter, ErrorRouteComponent, RouterProvider, useRouter } from '@t
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useApi } from './api/Api';
|
import { useApi } from './api/Api';
|
||||||
|
|
||||||
|
import ErrorRouterComponent from './components/Error/ErrorRouterComponent';
|
||||||
|
import NotFoundComponent from './components/NotFound/NotFoundComponent';
|
||||||
import { routeTree } from './routeTree.gen';
|
import { routeTree } from './routeTree.gen';
|
||||||
|
|
||||||
//TODO: REAUTH HERE
|
//TODO: REAUTH HERE
|
||||||
@@ -51,6 +53,8 @@ const router = createRouter({
|
|||||||
defaultPreloadStaleTime: 0,
|
defaultPreloadStaleTime: 0,
|
||||||
basepath: process.env.NODE_ENV === 'development' ? 'phpCourse/exam/dist' : '/phpCourse/exam',
|
basepath: process.env.NODE_ENV === 'development' ? 'phpCourse/exam/dist' : '/phpCourse/exam',
|
||||||
//defaultErrorComponent: Error,
|
//defaultErrorComponent: Error,
|
||||||
|
defaultNotFoundComponent: NotFoundComponent,
|
||||||
|
defaultErrorComponent: ErrorRouterComponent,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register the router instance for type safety
|
// Register the router instance for type safety
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { QueryClient } from '@tanstack/react-query';
|
|||||||
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router';
|
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router';
|
||||||
import { TanStackRouterDevtools } from '@tanstack/router-devtools';
|
import { TanStackRouterDevtools } from '@tanstack/router-devtools';
|
||||||
import { useApi } from '../api/Api';
|
import { useApi } from '../api/Api';
|
||||||
import ErrorRouterComponent from '../components/Error/ErrorRouterComponent';
|
|
||||||
import NotFoundComponent from '../components/NotFound/NotFoundComponent';
|
|
||||||
|
|
||||||
const Root = () => {
|
const Root = () => {
|
||||||
return (
|
return (
|
||||||
@@ -17,6 +15,4 @@ const Root = () => {
|
|||||||
|
|
||||||
export const Route = createRootRouteWithContext<{ queryClient: QueryClient; Api: ReturnType<typeof useApi> }>()({
|
export const Route = createRootRouteWithContext<{ queryClient: QueryClient; Api: ReturnType<typeof useApi> }>()({
|
||||||
component: Root,
|
component: Root,
|
||||||
notFoundComponent: NotFoundComponent,
|
|
||||||
errorComponent: ErrorRouterComponent,
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,9 +15,7 @@ const Home = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const confirmMutation = useMutation({
|
const confirmMutation = useMutation({
|
||||||
mutationFn: ({ code: _code }: { code: string }) => {
|
mutationFn: ({ code: _code }: { code: string }) => Api.confirmUser(_code),
|
||||||
return Api.confirmUser(_code);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const Home = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ((page ?? 0) >= postsQuery.pages) {
|
if ((page ?? 0) >= postsQuery.pages) {
|
||||||
navigate({ to: '/', search: { page: postsQuery.pages - 1 } });
|
navigate({ to: ROUTES.INDEX, search: { page: postsQuery.pages - 1 } });
|
||||||
}
|
}
|
||||||
}, [page]); //eslint-disable-line react-hooks/exhaustive-deps
|
}, [page]); //eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export const Route = createFileRoute(`${ROUTES.PROFILE}/$id`)({
|
|||||||
queryClient.ensureQueryData(profileQueryOptions(Api, id));
|
queryClient.ensureQueryData(profileQueryOptions(Api, id));
|
||||||
},
|
},
|
||||||
beforeLoad: ({ params: { id }, context: { Api } }) => {
|
beforeLoad: ({ params: { id }, context: { Api } }) => {
|
||||||
if (!Api.hasAuth) throw redirect({ to: ROUTES.INDEX });
|
if (!Api.hasAuth && !Api.currentSession[0]) throw redirect({ to: ROUTES.INDEX });
|
||||||
if (id === Api.authenticatedUser?.id) throw redirect({ to: ROUTES.PROFILE });
|
if (id === Api.authenticatedUser?.id) throw redirect({ to: ROUTES.PROFILE });
|
||||||
},
|
},
|
||||||
component: ProfilePage,
|
component: ProfilePage,
|
||||||
|
|||||||
@@ -36,10 +36,10 @@ const ProfilePage = () => {
|
|||||||
|
|
||||||
export const Route = createFileRoute(`${ROUTES.PROFILE}/`)({
|
export const Route = createFileRoute(`${ROUTES.PROFILE}/`)({
|
||||||
loader: ({ context: { queryClient, Api } }) => {
|
loader: ({ context: { queryClient, Api } }) => {
|
||||||
queryClient.ensureQueryData(profileSelfQueryOptions(Api));
|
if (Api.authenticatedUser) queryClient.ensureQueryData(profileSelfQueryOptions(Api));
|
||||||
},
|
},
|
||||||
beforeLoad: ({ context: { Api } }) => {
|
beforeLoad: ({ context: { Api } }) => {
|
||||||
if (!Api.hasAuth) throw redirect({ to: ROUTES.INDEX });
|
if (!Api.hasAuth && !Api.currentSession[0]) throw redirect({ to: ROUTES.INDEX });
|
||||||
},
|
},
|
||||||
component: ProfilePage,
|
component: ProfilePage,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardActions,
|
||||||
|
CardContent,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
Divider,
|
||||||
|
Grid,
|
||||||
|
Link as MUILink,
|
||||||
|
Pagination,
|
||||||
|
PaginationItem,
|
||||||
|
Snackbar,
|
||||||
|
Typography,
|
||||||
|
useMediaQuery,
|
||||||
|
useTheme,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
|
||||||
|
import { createFileRoute, Link, redirect, useNavigate } from '@tanstack/react-router';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useApi } from '../../api/Api';
|
||||||
|
import ErrorComponent from '../../components/Error/ErrorComponent';
|
||||||
|
import { ERRORS } from '../../components/Error/Errors';
|
||||||
|
import HeaderLayout from '../../components/Layouts/HeaderLayout';
|
||||||
|
import { usersQueryOptions } from '../../queries/usersQuery';
|
||||||
|
import { ROUTES } from '../../types/Routes';
|
||||||
|
import { UserPermissionsUpdate } from '../../types/User';
|
||||||
|
|
||||||
|
const Users = () => {
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
//eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const [_error, setError] = useState<any>();
|
||||||
|
|
||||||
|
const Api = useApi();
|
||||||
|
const { page } = Route.useSearch();
|
||||||
|
const { data: usersQuery, isFetching, failureReason, error } = useSuspenseQuery(usersQueryOptions(Api, page));
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const theme = useTheme();
|
||||||
|
const oneSibling = useMediaQuery(theme.breakpoints.not('xs'), { noSsr: true });
|
||||||
|
|
||||||
|
if (failureReason && 'code' in failureReason && failureReason.code === ERRORS.UNAUTHORIZED) {
|
||||||
|
throw failureReason;
|
||||||
|
}
|
||||||
|
if (error && 'code' in error && error.code === ERRORS.UNAUTHORIZED) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => Api.deleteUser(id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const permissionMutation = useMutation({
|
||||||
|
mutationFn: ({ data, id }: { data: UserPermissionsUpdate; id: number }) => Api.updateUserPermissions(data, id),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if ((page ?? 0) >= usersQuery.pages) {
|
||||||
|
navigate({ to: ROUTES.USERS, search: { page: usersQuery.pages - 1 } });
|
||||||
|
}
|
||||||
|
}, [page]); //eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HeaderLayout>
|
||||||
|
<Snackbar open={isFetching} message={t('Updating')} />
|
||||||
|
<Grid container spacing={2} sx={{ marginTop: 0 }}>
|
||||||
|
{usersQuery.data.map((user) => (
|
||||||
|
<Grid item xs={12} key={user.id}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
{user.id !== Api.authenticatedUser?.id ? (
|
||||||
|
<MUILink component={Link} to="/profile/$id" params={{ id: user.id }}>
|
||||||
|
{user.username}
|
||||||
|
</MUILink>
|
||||||
|
) : (
|
||||||
|
<Typography>{user.username}</Typography>
|
||||||
|
)}
|
||||||
|
<Typography>{user.email}</Typography>
|
||||||
|
</CardContent>
|
||||||
|
{user.id !== Api.authenticatedUser?.id && (
|
||||||
|
<>
|
||||||
|
<CardActions>
|
||||||
|
<Button size="small" color="error" onClick={() => setDeleteOpen(true)}>
|
||||||
|
{t('Delete')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
permissionMutation.mutate(
|
||||||
|
{ data: { isAdmin: !user.isAdmin }, id: user.id },
|
||||||
|
{
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ['users'],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: setError,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t(user.isAdmin ? 'Demote Admin' : 'Make Admin')}
|
||||||
|
</Button>
|
||||||
|
<Dialog open={deleteOpen} onClose={() => setDeleteOpen(false)}>
|
||||||
|
<DialogTitle>{t('Confirm user delete title')}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>{t('Confirm user delete body', { name: user.username })}</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDeleteOpen(false)} autoFocus variant="contained">
|
||||||
|
{t('No')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
onClick={() => {
|
||||||
|
deleteMutation.mutate(user.id, {
|
||||||
|
onSuccess: async (data) => {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ['users'],
|
||||||
|
});
|
||||||
|
if ((page ?? 0) >= data.pages)
|
||||||
|
navigate({ to: ROUTES.PROFILE, search: { page: data.pages - 1 } });
|
||||||
|
},
|
||||||
|
onError: setError,
|
||||||
|
});
|
||||||
|
setDeleteOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Yes')}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</CardActions>
|
||||||
|
<Snackbar
|
||||||
|
open={deleteMutation.isError || permissionMutation.isError}
|
||||||
|
autoHideDuration={2000}
|
||||||
|
onClose={() => {
|
||||||
|
deleteMutation.reset();
|
||||||
|
permissionMutation.reset();
|
||||||
|
}}
|
||||||
|
TransitionProps={{
|
||||||
|
onExited: () => setError(undefined),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Alert severity="error" variant="filled" sx={{ width: '100%' }}>
|
||||||
|
{error && <ErrorComponent error={_error} context="delete|PermissionsUser" color="white" />}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
<Snackbar open={deleteMutation.isPending} message={t('Deleting')} />
|
||||||
|
<Snackbar open={permissionMutation.isPending} message={t('Updating')} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Divider variant="middle" />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<Pagination
|
||||||
|
page={(page ?? 0) + 1}
|
||||||
|
count={usersQuery.pages}
|
||||||
|
siblingCount={oneSibling ? 1 : 0}
|
||||||
|
color="primary"
|
||||||
|
renderItem={(item) => (
|
||||||
|
<PaginationItem
|
||||||
|
{...item}
|
||||||
|
component={Link}
|
||||||
|
to="/"
|
||||||
|
search={{ page: (item.page ?? 0) > 0 ? (item.page ?? 1) - 1 : undefined }}
|
||||||
|
//eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
onClick={(e) => item.onClick(e as any)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</HeaderLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Route = createFileRoute(`${ROUTES.USERS}/`)({
|
||||||
|
loaderDeps: ({ search: { page } }) => ({ page }),
|
||||||
|
loader: ({ context: { queryClient, Api }, deps: { page } }) =>
|
||||||
|
queryClient.ensureQueryData(usersQueryOptions(Api, page)),
|
||||||
|
validateSearch: (search: Record<string, unknown>): { page?: number } => {
|
||||||
|
return {
|
||||||
|
page: search?.page !== undefined ? Number(search?.page ?? 0) : undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
beforeLoad: ({ context: { Api } }) => {
|
||||||
|
if ((!Api.hasAuth && !Api.currentSession[0]) || !Api.authenticatedUser?.isAdmin)
|
||||||
|
throw redirect({ to: ROUTES.INDEX });
|
||||||
|
},
|
||||||
|
component: Users,
|
||||||
|
});
|
||||||
@@ -2,4 +2,5 @@ export enum ROUTES {
|
|||||||
INDEX = '/',
|
INDEX = '/',
|
||||||
PROFILE = '/profile',
|
PROFILE = '/profile',
|
||||||
CONFIRM = '/confirm',
|
CONFIRM = '/confirm',
|
||||||
|
USERS = '/users',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export interface UserUpdate {
|
|||||||
password?: string;
|
password?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserPermissionsUpdate {
|
||||||
|
isAdmin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserCreate {
|
export interface UserCreate {
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -32,3 +36,13 @@ export interface UserImageUpdate {
|
|||||||
image?: File;
|
image?: File;
|
||||||
predefined?: string;
|
predefined?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserList {
|
||||||
|
pages: number;
|
||||||
|
data: User[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserDelete {
|
||||||
|
pages: number;
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+2
@@ -1 +1,3 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare const __APP_VERSION__: string;
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { defineConfig } from 'vite';
|
|||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [TanStackRouterVite(), react(), visualizer({ emitFile: true, open: true, filename: 'stats.html' })],
|
plugins: [TanStackRouterVite(), react(), visualizer({ emitFile: true, open: true, filename: 'stats.html' })],
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(process.env.npm_package_version),
|
||||||
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: '../dist',
|
outDir: '../dist',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ SimpleRouter::group(["middleware" => Khofmann\Auth\AdminAuth::class], function (
|
|||||||
SimpleRouter::patch("/users/{id}", [Api\Users\Users::class, "patch"]);
|
SimpleRouter::patch("/users/{id}", [Api\Users\Users::class, "patch"]);
|
||||||
// Update image
|
// Update image
|
||||||
SimpleRouter::post("/users/{id}/image", [Api\Users\Image\Image::class, "post"]);
|
SimpleRouter::post("/users/{id}/image", [Api\Users\Image\Image::class, "post"]);
|
||||||
|
// Update permissions
|
||||||
|
SimpleRouter::patch("/users/{id}/permissions", [Api\Users\Permissions\Permissions::class, "patch"]);
|
||||||
// Delete user
|
// Delete user
|
||||||
SimpleRouter::delete("/users/{id}", [Api\Users\Users::class, "delete"]);
|
SimpleRouter::delete("/users/{id}", [Api\Users\Users::class, "delete"]);
|
||||||
// Delete post
|
// Delete post
|
||||||
|
|||||||
Reference in New Issue
Block a user