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
|
||||
- Node 18
|
||||
- PNPM
|
||||
@@ -18,7 +100,7 @@
|
||||
## Tabelle `egb_gaestebuch`
|
||||
- Abänderung der Spalte `benutzer_id`: 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
|
||||
|
||||
|
||||
@@ -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:
|
||||
- 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:
|
||||
get:
|
||||
summary: Get user posts
|
||||
@@ -816,6 +867,11 @@ components:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
UserPermissionsUpdateRequest:
|
||||
type: object
|
||||
properties:
|
||||
isAdmin:
|
||||
type: boolean
|
||||
UserImageUpdateRequest:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -339,7 +339,7 @@ class User implements JsonSerializable
|
||||
$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
|
||||
*
|
||||
@@ -515,7 +515,7 @@ class User implements JsonSerializable
|
||||
}
|
||||
|
||||
/**
|
||||
* Update post
|
||||
* Update user
|
||||
*
|
||||
* Does nothing if all fields are empty
|
||||
*
|
||||
@@ -528,26 +528,21 @@ class User implements JsonSerializable
|
||||
{
|
||||
$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
|
||||
$db->beginTransaction();
|
||||
|
||||
$failed = [];
|
||||
$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)) {
|
||||
// Move file and grab filename
|
||||
// Generate new filename
|
||||
$destinationFilename = sprintf('%s.%s', uniqid(), $image->getExtension());
|
||||
$image->move(Config::getStorageFSPath() . "profilbilder/$destinationFilename");
|
||||
|
||||
// Try and update user record
|
||||
try {
|
||||
$stmt = $db->prepare("UPDATE egb_benutzer SET image = :IMG WHERE id = :ID");
|
||||
$stmt->bindValue(":IMG", "profilbilder/$destinationFilename");
|
||||
@@ -557,6 +552,8 @@ class User implements JsonSerializable
|
||||
array_push($failed, "image");
|
||||
array_push($reasons, "generic");
|
||||
}
|
||||
// Move file
|
||||
$image->move(Config::getStorageFSPath() . "profilbilder/$destinationFilename");
|
||||
} catch (Exception $e) {
|
||||
array_push($failed, "image");
|
||||
if ($e->getCode() === "23000") {
|
||||
@@ -594,11 +591,79 @@ class User implements JsonSerializable
|
||||
// Commit the changes
|
||||
$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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -609,6 +674,7 @@ class User implements JsonSerializable
|
||||
$db = Database::getInstance();
|
||||
$stmt = $db->prepare("DELETE FROM egb_benutzer WHERE id = :ID");
|
||||
$stmt->bindValue(":ID", $this->id);
|
||||
$stmt->execute();
|
||||
|
||||
$stmt = $db->prepare(
|
||||
"SELECT
|
||||
@@ -619,6 +685,12 @@ class User implements JsonSerializable
|
||||
$stmt->execute();
|
||||
$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];
|
||||
}
|
||||
|
||||
|
||||
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" />
|
||||
<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/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/i18n-Be01V9yD.js">
|
||||
<link rel="stylesheet" crossorigin href="/phpCourse/exam/dist/assets/mui-CKDNpdid.css">
|
||||
|
||||
+16
-1
@@ -20,6 +20,9 @@
|
||||
"NotAllowed_postUpdate": "Keine Berechtigung",
|
||||
"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",
|
||||
|
||||
"username": "Benutzername",
|
||||
@@ -97,5 +100,17 @@
|
||||
"Session expired": "Deine Sitzung ist abgelaufen.",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
"username": "username",
|
||||
@@ -98,5 +101,17 @@
|
||||
"Session expired": "Your session has expired.",
|
||||
"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",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "3.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
"NotAllowed_postUpdate": "Keine Berechtigung",
|
||||
"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",
|
||||
|
||||
"username": "Benutzername",
|
||||
@@ -97,5 +100,17 @@
|
||||
"Session expired": "Deine Sitzung ist abgelaufen.",
|
||||
"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",
|
||||
"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",
|
||||
|
||||
"username": "username",
|
||||
@@ -98,5 +101,17 @@
|
||||
"Session expired": "Your session has expired.",
|
||||
"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 { ERRORS } from '../components/Error/Errors';
|
||||
import { POST_LIMIT, PROFILE_POST_LIMIT } from '../constanst';
|
||||
import useGuestBookStore from '../store/store';
|
||||
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/';
|
||||
|
||||
@@ -19,11 +28,14 @@ interface ApiContext {
|
||||
updatePost?: (data: PostUpdate, id: number) => Promise<PostAuth>;
|
||||
deletePost?: (id: number) => Promise<PostDelete>;
|
||||
|
||||
users?: (page?: number) => Promise<UserList>;
|
||||
user?: (id?: number) => Promise<User>;
|
||||
createUser?: (data: UserCreate) => Promise<User>;
|
||||
confirmUser?: (code: string) => Promise<User>;
|
||||
updateUser?: (data: UserUpdate, 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>;
|
||||
}
|
||||
@@ -47,11 +59,14 @@ export const useApi = () => {
|
||||
updatePost,
|
||||
deletePost,
|
||||
|
||||
users,
|
||||
user,
|
||||
createUser,
|
||||
confirmUser,
|
||||
updateUser,
|
||||
updateUserImage,
|
||||
updateUserPermissions,
|
||||
deleteUser,
|
||||
|
||||
userPosts,
|
||||
} = useContext(ApiContext);
|
||||
@@ -63,11 +78,14 @@ export const useApi = () => {
|
||||
newPost &&
|
||||
updatePost &&
|
||||
deletePost &&
|
||||
users &&
|
||||
user &&
|
||||
createUser &&
|
||||
confirmUser &&
|
||||
updateUser &&
|
||||
updateUserImage &&
|
||||
updateUserPermissions &&
|
||||
deleteUser &&
|
||||
userPosts
|
||||
) {
|
||||
return {
|
||||
@@ -82,11 +100,14 @@ export const useApi = () => {
|
||||
updatePost,
|
||||
deletePost,
|
||||
|
||||
users,
|
||||
user,
|
||||
createUser,
|
||||
confirmUser,
|
||||
updateUser,
|
||||
updateUserImage,
|
||||
updateUserPermissions,
|
||||
deleteUser,
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
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> => {
|
||||
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;
|
||||
};
|
||||
|
||||
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> => {
|
||||
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 */
|
||||
|
||||
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();
|
||||
|
||||
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 {
|
||||
console.log('[REAUTH] fail, refreshing');
|
||||
console.log('[REAUTH] failed due to authentication, try refreshing session');
|
||||
// REAUTH
|
||||
await refresh();
|
||||
// DO AGAIN
|
||||
@@ -294,13 +336,13 @@ export const ApiProvider: FC<PropsWithChildren<Record<string, unknown>>> = ({ ch
|
||||
const ret = await callback();
|
||||
|
||||
return ret;
|
||||
} catch (error) {
|
||||
console.log('[REAUTH] terminating session', error);
|
||||
} catch (_error) {
|
||||
console.log('[REAUTH] terminating session', _error);
|
||||
setAuthenticatedUser(undefined);
|
||||
setHasAuth(false);
|
||||
setCurrentSession([undefined, undefined]);
|
||||
token.current = undefined;
|
||||
throw error;
|
||||
throw _error;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -332,11 +374,14 @@ export const ApiProvider: FC<PropsWithChildren<Record<string, unknown>>> = ({ ch
|
||||
updatePost,
|
||||
deletePost,
|
||||
|
||||
users,
|
||||
user,
|
||||
createUser,
|
||||
confirmUser,
|
||||
updateUser,
|
||||
updateUserImage,
|
||||
updateUserPermissions,
|
||||
deleteUser,
|
||||
|
||||
userPosts,
|
||||
}}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { useForm } from '@tanstack/react-form';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { t } from 'i18next';
|
||||
import { FC, FormEvent, useState } from 'react';
|
||||
import { FC, FormEvent, useEffect, useState } from 'react';
|
||||
import { useApi } from '../../../api/Api';
|
||||
import { PostAuth, PostUpdate } from '../../../types/Post';
|
||||
import ErrorComponent from '../../Error/ErrorComponent';
|
||||
@@ -35,9 +35,7 @@ const PostEditDialog: FC<Props> = ({ post, open, onClose }) => {
|
||||
const Api = useApi();
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ data, id }: { data: PostUpdate; id: number }) => {
|
||||
return Api.updatePost(data, id);
|
||||
},
|
||||
mutationFn: ({ data, id }: { data: PostUpdate; id: number }) => Api.updatePost(data, id),
|
||||
});
|
||||
|
||||
const form = useForm<PostUpdate>({
|
||||
@@ -71,6 +69,10 @@ const PostEditDialog: FC<Props> = ({ post, open, onClose }) => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!Api.hasAuth) handleClose();
|
||||
}, [Api.hasAuth]); //eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
|
||||
@@ -35,21 +35,26 @@ const RegisterDialog: FC<Props> = ({ open, onClose }) => {
|
||||
const Api = useApi();
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: ({ data }: { data: UserCreate }) => {
|
||||
return Api.createUser(data);
|
||||
},
|
||||
mutationFn: ({ data }: { data: UserCreate }) => Api.createUser(data),
|
||||
});
|
||||
|
||||
const form = useForm<UserCreate>({
|
||||
const form = useForm<UserCreate & { passwordConfirm: string }>({
|
||||
defaultValues: {
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
passwordConfirm: '',
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
try {
|
||||
createMutation.mutate(
|
||||
{ data: value },
|
||||
{
|
||||
data: {
|
||||
username: value.username,
|
||||
email: value.email,
|
||||
password: value.password,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => setError(undefined),
|
||||
onError: setError,
|
||||
@@ -113,7 +118,7 @@ const RegisterDialog: FC<Props> = ({ open, onClose }) => {
|
||||
}}
|
||||
>
|
||||
<DialogTitle>{t('Register')}</DialogTitle>
|
||||
<DialogContent sx={{ gap: 2 }}>
|
||||
<DialogContent>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<form.Field
|
||||
@@ -215,6 +220,48 @@ const RegisterDialog: FC<Props> = ({ open, onClose }) => {
|
||||
}}
|
||||
/>
|
||||
</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}>
|
||||
<form.Subscribe
|
||||
selector={(state) => [state.canSubmit, state.isSubmitting]}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Grid,
|
||||
TextField,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
import { useForm } from '@tanstack/react-form';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { t } from 'i18next';
|
||||
import { FC, FormEvent, useState } from 'react';
|
||||
import { FC, FormEvent, useEffect, useState } from 'react';
|
||||
import { useApi } from '../../../api/Api';
|
||||
import { User, UserUpdate } from '../../../types/User';
|
||||
import ErrorComponent from '../../Error/ErrorComponent';
|
||||
@@ -33,9 +34,7 @@ const UserEditDialog: FC<Props> = ({ user, open, onClose }) => {
|
||||
const Api = useApi();
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ data, id }: { data: UserUpdate; id?: number }) => {
|
||||
return Api.updateUser(data, id);
|
||||
},
|
||||
mutationFn: ({ data, id }: { data: UserUpdate; id?: number }) => Api.updateUser(data, id),
|
||||
});
|
||||
|
||||
const form = useForm<UserUpdate>({
|
||||
@@ -71,6 +70,10 @@ const UserEditDialog: FC<Props> = ({ user, open, onClose }) => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!Api.hasAuth) handleClose();
|
||||
}, [Api.hasAuth]); //eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
@@ -94,66 +97,72 @@ const UserEditDialog: FC<Props> = ({ user, open, onClose }) => {
|
||||
>
|
||||
<DialogTitle>{t('Edit data')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<form.Field
|
||||
name="username"
|
||||
validators={{
|
||||
onChange: ({ value }) => (!value ? t('Username required') : undefined),
|
||||
onChangeAsyncDebounceMs: 250,
|
||||
onChangeAsync: async ({ value }) => {
|
||||
return !value && t('Username required');
|
||||
},
|
||||
}}
|
||||
children={(field) => {
|
||||
return (
|
||||
<>
|
||||
<TextField
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
size="small"
|
||||
label={t('Username')}
|
||||
required
|
||||
margin="dense"
|
||||
autoComplete="new-username"
|
||||
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={{
|
||||
onChange: ({ value }) => (!value ? t('Email required') : undefined),
|
||||
onChangeAsyncDebounceMs: 250,
|
||||
onChangeAsync: async ({ value }) => {
|
||||
return !value && t('Email required');
|
||||
},
|
||||
}}
|
||||
children={(field) => {
|
||||
return (
|
||||
<>
|
||||
<TextField
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
size="small"
|
||||
label={t('Email')}
|
||||
required
|
||||
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 container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<form.Field
|
||||
name="username"
|
||||
validators={{
|
||||
onChange: ({ value }) => (!value ? t('Username required') : undefined),
|
||||
onChangeAsyncDebounceMs: 250,
|
||||
onChangeAsync: async ({ value }) => {
|
||||
return !value && t('Username required');
|
||||
},
|
||||
}}
|
||||
children={(field) => {
|
||||
return (
|
||||
<>
|
||||
<TextField
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
size="small"
|
||||
label={t('Username')}
|
||||
required
|
||||
margin="dense"
|
||||
autoComplete="new-username"
|
||||
fullWidth
|
||||
error={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
helperText={field.state.meta.isTouched ? field.state.meta.errors.join(',') : ''}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<form.Field
|
||||
name="email"
|
||||
validators={{
|
||||
onChange: ({ value }) => (!value ? t('Email required') : undefined),
|
||||
onChangeAsyncDebounceMs: 250,
|
||||
onChangeAsync: async ({ value }) => {
|
||||
return !value && t('Email required');
|
||||
},
|
||||
}}
|
||||
children={(field) => {
|
||||
return (
|
||||
<>
|
||||
<TextField
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
size="small"
|
||||
label={t('Email')}
|
||||
required
|
||||
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>
|
||||
<DialogActions>
|
||||
<form.Subscribe
|
||||
|
||||
+6
-4
@@ -23,7 +23,7 @@ import {
|
||||
import { useForm } from '@tanstack/react-form';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { t } from 'i18next';
|
||||
import { FC, FormEvent, useState } from 'react';
|
||||
import { FC, FormEvent, useEffect, useState } from 'react';
|
||||
import { useApi } from '../../../api/Api';
|
||||
import { User, UserImageUpdate } from '../../../types/User';
|
||||
import ErrorComponent from '../../Error/ErrorComponent';
|
||||
@@ -44,9 +44,7 @@ const UserImageDialog: FC<Props> = ({ user, open, onClose }) => {
|
||||
const Api = useApi();
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ data, id }: { data: UserImageUpdate; id?: number }) => {
|
||||
return Api.updateUserImage(data, id);
|
||||
},
|
||||
mutationFn: ({ data, id }: { data: UserImageUpdate; id?: number }) => Api.updateUserImage(data, id),
|
||||
});
|
||||
|
||||
const form = useForm<UserImageUpdate>({
|
||||
@@ -79,6 +77,10 @@ const UserImageDialog: FC<Props> = ({ user, open, onClose }) => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!Api.hasAuth) handleClose();
|
||||
}, [Api.hasAuth]); //eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
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 { 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 { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -13,10 +13,11 @@ const ErrorRouterComponent: TSErrorRouteComponent = ({ error }) => {
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
const queryErrorResetBoundary = useQueryErrorResetBoundary();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
// Reset the query error boundary
|
||||
console.log(queryErrorResetBoundary.isReset());
|
||||
console.log(queryErrorResetBoundary);
|
||||
queryErrorResetBoundary.reset();
|
||||
}, [queryErrorResetBoundary]);
|
||||
|
||||
@@ -34,9 +35,9 @@ const ErrorRouterComponent: TSErrorRouteComponent = ({ error }) => {
|
||||
<Grid item xs={12} sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<MUILink
|
||||
variant="h6"
|
||||
underline="none"
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
console.log('CLICK AS WELL');
|
||||
queryClient.clear();
|
||||
router.invalidate();
|
||||
navigate({ to: ROUTES.INDEX });
|
||||
}}
|
||||
|
||||
@@ -25,7 +25,7 @@ const Footer: FC = () => {
|
||||
</Link>
|
||||
</Grid>
|
||||
<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>
|
||||
</Box>
|
||||
|
||||
@@ -19,9 +19,7 @@ const PostForm: FC = () => {
|
||||
const Api = useApi();
|
||||
|
||||
const newMutation = useMutation({
|
||||
mutationFn: ({ data }: { data: PostCreate }) => {
|
||||
return Api.newPost(data);
|
||||
},
|
||||
mutationFn: ({ data }: { data: PostCreate }) => Api.newPost(data),
|
||||
});
|
||||
|
||||
const form = useForm<PostCreate>({
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { Link, useRouterState } from '@tanstack/react-router';
|
||||
import { cloneElement, FC, ReactElement, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import GuestBook from '../../../assets/img/guests-book.png';
|
||||
import { useApi } from '../../api/Api';
|
||||
import useGuestBookStore from '../../store/store';
|
||||
import LanguageMenu from '../Menus/Language/LanguageMenu';
|
||||
@@ -51,8 +52,15 @@ const Header: FC = () => {
|
||||
<AppBar>
|
||||
<Toolbar>
|
||||
<Box sx={{ flexGrow: 1, alignItems: 'center', display: 'flex', gap: 1 }}>
|
||||
<MUILink component={Link} to="/" color="white" variant="h6" underline="none">
|
||||
{t('GuestBook')}
|
||||
<MUILink
|
||||
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>
|
||||
{isLoading && <CircularProgress size={16} thickness={10} sx={{ color: 'white' }} />}
|
||||
</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 { t } from 'i18next';
|
||||
import { FC, useState } from 'react';
|
||||
@@ -69,6 +69,14 @@ const UserMenu: FC<Props> = ({ anchorEl, handleClose }) => {
|
||||
>
|
||||
{t('Log out')}
|
||||
</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 ? (
|
||||
<RegisterDialog open={register} onClose={() => setRegister(false)} />
|
||||
@@ -81,7 +89,6 @@ const UserMenu: FC<Props> = ({ anchorEl, handleClose }) => {
|
||||
<Link
|
||||
sx={{ cursor: 'pointer' }}
|
||||
variant="body1"
|
||||
underline="hover"
|
||||
color="secondary.main"
|
||||
onClick={() => {
|
||||
setRegister(true);
|
||||
|
||||
@@ -44,9 +44,7 @@ const Post: FC<Props> = ({ page = 0, post, disableActions }) => {
|
||||
const Api = useApi();
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => {
|
||||
return Api.deletePost(id);
|
||||
},
|
||||
mutationFn: (id: number) => Api.deletePost(id),
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -17,7 +17,8 @@ import { PostAuth } from '../../types/Post';
|
||||
import { User } from '../../types/User';
|
||||
import convertDate from '../../utils/date';
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
@@ -29,6 +30,7 @@ interface Props {
|
||||
const Profile: FC<Props> = ({ user, posts, canEdit }) => {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [imageOpen, setImageOpen] = useState(false);
|
||||
const [passwordOpen, setPasswordOpen] = useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -39,11 +41,17 @@ const Profile: FC<Props> = ({ user, posts, canEdit }) => {
|
||||
<CardContent>
|
||||
<Grid container spacing={2}>
|
||||
<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' }}>
|
||||
<Person sx={{ width: '60px', height: '60px' }} />
|
||||
</Avatar>
|
||||
</IconButton>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid item sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<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)}>
|
||||
{t('Edit')}
|
||||
</Button>
|
||||
<Button size="small" color="secondary" onClick={() => setPasswordOpen(true)}>
|
||||
{t('Change password')}
|
||||
</Button>
|
||||
<UserEditDialog user={user} open={editOpen} onClose={() => setEditOpen(false)} />
|
||||
<UserImageDialog user={user} open={imageOpen} onClose={() => setImageOpen(false)} />
|
||||
<UserPasswordDialog user={user} open={passwordOpen} onClose={() => setPasswordOpen(false)} />
|
||||
</>
|
||||
)}
|
||||
</CardActions>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import { useApi } from '../api/Api';
|
||||
import { ERRORS } from '../components/Error/Errors';
|
||||
|
||||
export const profileSelfQueryOptions = (Api: ReturnType<typeof useApi>) =>
|
||||
queryOptions({
|
||||
@@ -8,10 +9,12 @@ export const profileSelfQueryOptions = (Api: ReturnType<typeof useApi>) =>
|
||||
user: await Api.user(),
|
||||
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) =>
|
||||
queryOptions({
|
||||
queryKey: ['profile', { 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 IndexImport } from './routes/index'
|
||||
import { Route as UsersIndexImport } from './routes/users/index'
|
||||
import { Route as ProfileIndexImport } from './routes/profile/index'
|
||||
import { Route as ConfirmIndexImport } from './routes/confirm/index'
|
||||
import { Route as ProfileIdImport } from './routes/profile/$id'
|
||||
@@ -23,6 +24,11 @@ const IndexRoute = IndexImport.update({
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const UsersIndexRoute = UsersIndexImport.update({
|
||||
path: '/users/',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const ProfileIndexRoute = ProfileIndexImport.update({
|
||||
path: '/profile/',
|
||||
getParentRoute: () => rootRoute,
|
||||
@@ -70,6 +76,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ProfileIndexImport
|
||||
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,
|
||||
ConfirmIndexRoute,
|
||||
ProfileIndexRoute,
|
||||
UsersIndexRoute,
|
||||
})
|
||||
|
||||
/* prettier-ignore-end */
|
||||
@@ -93,7 +107,8 @@ export const routeTree = rootRoute.addChildren({
|
||||
"/",
|
||||
"/profile/$id",
|
||||
"/confirm/",
|
||||
"/profile/"
|
||||
"/profile/",
|
||||
"/users/"
|
||||
]
|
||||
},
|
||||
"/": {
|
||||
@@ -107,6 +122,9 @@ export const routeTree = rootRoute.addChildren({
|
||||
},
|
||||
"/profile/": {
|
||||
"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 { useApi } from './api/Api';
|
||||
|
||||
import ErrorRouterComponent from './components/Error/ErrorRouterComponent';
|
||||
import NotFoundComponent from './components/NotFound/NotFoundComponent';
|
||||
import { routeTree } from './routeTree.gen';
|
||||
|
||||
//TODO: REAUTH HERE
|
||||
@@ -51,6 +53,8 @@ const router = createRouter({
|
||||
defaultPreloadStaleTime: 0,
|
||||
basepath: process.env.NODE_ENV === 'development' ? 'phpCourse/exam/dist' : '/phpCourse/exam',
|
||||
//defaultErrorComponent: Error,
|
||||
defaultNotFoundComponent: NotFoundComponent,
|
||||
defaultErrorComponent: ErrorRouterComponent,
|
||||
});
|
||||
|
||||
// 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 { TanStackRouterDevtools } from '@tanstack/router-devtools';
|
||||
import { useApi } from '../api/Api';
|
||||
import ErrorRouterComponent from '../components/Error/ErrorRouterComponent';
|
||||
import NotFoundComponent from '../components/NotFound/NotFoundComponent';
|
||||
|
||||
const Root = () => {
|
||||
return (
|
||||
@@ -17,6 +15,4 @@ const Root = () => {
|
||||
|
||||
export const Route = createRootRouteWithContext<{ queryClient: QueryClient; Api: ReturnType<typeof useApi> }>()({
|
||||
component: Root,
|
||||
notFoundComponent: NotFoundComponent,
|
||||
errorComponent: ErrorRouterComponent,
|
||||
});
|
||||
|
||||
@@ -15,9 +15,7 @@ const Home = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const confirmMutation = useMutation({
|
||||
mutationFn: ({ code: _code }: { code: string }) => {
|
||||
return Api.confirmUser(_code);
|
||||
},
|
||||
mutationFn: ({ code: _code }: { code: string }) => Api.confirmUser(_code),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -30,7 +30,7 @@ const Home = () => {
|
||||
|
||||
useEffect(() => {
|
||||
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
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ export const Route = createFileRoute(`${ROUTES.PROFILE}/$id`)({
|
||||
queryClient.ensureQueryData(profileQueryOptions(Api, id));
|
||||
},
|
||||
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 });
|
||||
},
|
||||
component: ProfilePage,
|
||||
|
||||
@@ -36,10 +36,10 @@ const ProfilePage = () => {
|
||||
|
||||
export const Route = createFileRoute(`${ROUTES.PROFILE}/`)({
|
||||
loader: ({ context: { queryClient, Api } }) => {
|
||||
queryClient.ensureQueryData(profileSelfQueryOptions(Api));
|
||||
if (Api.authenticatedUser) queryClient.ensureQueryData(profileSelfQueryOptions(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,
|
||||
});
|
||||
|
||||
@@ -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 = '/',
|
||||
PROFILE = '/profile',
|
||||
CONFIRM = '/confirm',
|
||||
USERS = '/users',
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ export interface UserUpdate {
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface UserPermissionsUpdate {
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
export interface UserCreate {
|
||||
username: string;
|
||||
email: string;
|
||||
@@ -32,3 +36,13 @@ export interface UserImageUpdate {
|
||||
image?: File;
|
||||
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" />
|
||||
|
||||
declare const __APP_VERSION__: string;
|
||||
|
||||
@@ -6,6 +6,9 @@ import { defineConfig } from 'vite';
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [TanStackRouterVite(), react(), visualizer({ emitFile: true, open: true, filename: 'stats.html' })],
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(process.env.npm_package_version),
|
||||
},
|
||||
build: {
|
||||
outDir: '../dist',
|
||||
emptyOutDir: true,
|
||||
|
||||
@@ -78,6 +78,8 @@ SimpleRouter::group(["middleware" => Khofmann\Auth\AdminAuth::class], function (
|
||||
SimpleRouter::patch("/users/{id}", [Api\Users\Users::class, "patch"]);
|
||||
// Update image
|
||||
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
|
||||
SimpleRouter::delete("/users/{id}", [Api\Users\Users::class, "delete"]);
|
||||
// Delete post
|
||||
|
||||
Reference in New Issue
Block a user