Compare commits

..

17 Commits

Author SHA1 Message Date
Kilian a283d755de Adj. README 2024-07-30 21:37:49 +02:00
Kilian 4da6e6fb5f Permission change session invalidation 2024-07-30 21:25:05 +02:00
Kilian 0fbbfdc997 Admin 2024-07-30 21:20:51 +02:00
Kilian b1061e67ac Make admin 2024-07-30 21:09:42 +02:00
Kilian ae31f57ee0 Exec. delete 2024-07-30 19:06:40 +02:00
Kilian 28955feda7 Fix pages on users 2024-07-30 16:47:03 +02:00
Kilian c192eeba65 Refresh on profile with valid session 2024-07-30 00:24:43 +02:00
Kilian 45778d83d0 Merge branch 'master' of https://git.hofmannnet.myhome-server.de/Kilian/PHP-Course 2024-07-30 00:14:38 +02:00
Kilian bf3cebcf86 PW Change 2024-07-30 00:13:55 +02:00
Kilian 478f2429f5 File deletes 2024-07-29 23:48:36 +02:00
Kilian f6a10c8133 Conditional retry 2024-07-29 23:29:56 +02:00
Kilian 386472e680 Profile does not retry 2024-07-29 23:27:18 +02:00
Kilian 9cb34839ec Delete only if exists 2024-07-29 23:24:12 +02:00
Kilian bc5c04c7be Build it lol 2024-07-29 23:10:52 +02:00
Kilian 702bb94004 App version 2024-07-29 23:09:48 +02:00
Kilian b455fdb20e Readme 2024-07-29 23:09:37 +02:00
Kilian 34b7b47d0e Logo 2024-07-29 22:29:21 +02:00
45 changed files with 1079 additions and 154 deletions
+83 -1
View File
@@ -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;
}
}
}
}
+56
View File
@@ -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
+89 -17
View File
@@ -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];
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1 -1
View File
@@ -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",
+16 -1
View File
@@ -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
View File
@@ -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"
} }
+51 -6
View File
@@ -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,6 +97,8 @@ const UserEditDialog: FC<Props> = ({ user, open, onClose }) => {
> >
<DialogTitle>{t('Edit data')}</DialogTitle> <DialogTitle>{t('Edit data')}</DialogTitle>
<DialogContent> <DialogContent>
<Grid container spacing={2}>
<Grid item xs={12}>
<form.Field <form.Field
name="username" name="username"
validators={{ validators={{
@@ -124,6 +129,8 @@ const UserEditDialog: FC<Props> = ({ user, open, onClose }) => {
); );
}} }}
/> />
</Grid>
<Grid item xs={12}>
<form.Field <form.Field
name="email" name="email"
validators={{ validators={{
@@ -154,6 +161,8 @@ const UserEditDialog: FC<Props> = ({ user, open, onClose }) => {
); );
}} }}
/> />
</Grid>
</Grid>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<form.Subscribe <form.Subscribe
@@ -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 });
}} }}
+1 -1
View File
@@ -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>({
+10 -2
View File
@@ -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);
+1 -3
View File
@@ -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 (
+13 -1
View File
@@ -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' }}>
{canEdit ? (
<IconButton onClick={() => setImageOpen(true)}> <IconButton onClick={() => setImageOpen(true)}>
<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> </IconButton>
) : (
<Avatar alt={user.username} src={`${user.image}`} sx={{ width: '100px', height: '100px' }}>
<Person sx={{ width: '60px', height: '60px' }} />
</Avatar>
)}
</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>
+3
View File
@@ -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),
}); });
+10
View File
@@ -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),
});
+19 -1
View File
@@ -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"
} }
} }
} }
+4
View File
@@ -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
-4
View File
@@ -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,
}); });
+1 -3
View File
@@ -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(() => {
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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,
+2 -2
View File
@@ -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,
}); });
+203
View File
@@ -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,
});
+1
View File
@@ -2,4 +2,5 @@ export enum ROUTES {
INDEX = '/', INDEX = '/',
PROFILE = '/profile', PROFILE = '/profile',
CONFIRM = '/confirm', CONFIRM = '/confirm',
USERS = '/users',
} }
+14
View File
@@ -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;
}
+2
View File
@@ -1 +1,3 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
declare const __APP_VERSION__: string;
+3
View File
@@ -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,
+2
View File
@@ -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