diff options
author | crupest <crupest@outlook.com> | 2020-11-22 09:53:31 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-11-22 09:53:31 +0800 |
commit | b3c9cf4bf66bd3b78e94dc522c53e7f7522897f0 (patch) | |
tree | 996eed1f087a50e8d5cd865e1440f4dbb8a7873b | |
parent | e0785b385138057a23ffd1703a7265c371aef45d (diff) | |
parent | f1aabc06f1005b26bd1c0c5f36c98c28a62fc31e (diff) | |
download | timeline-b3c9cf4bf66bd3b78e94dc522c53e7f7522897f0.tar.gz timeline-b3c9cf4bf66bd3b78e94dc522c53e7f7522897f0.tar.bz2 timeline-b3c9cf4bf66bd3b78e94dc522c53e7f7522897f0.zip |
Merge pull request #189 from crupest/admin
Refactor front end to use the new permission system. Enhance admin page.
29 files changed, 659 insertions, 479 deletions
diff --git a/BackEnd/Timeline.Tests/IntegratedTests/UserPermissionTest.cs b/BackEnd/Timeline.Tests/IntegratedTests/UserPermissionTest.cs index 3fb581f0..ba3d893e 100644 --- a/BackEnd/Timeline.Tests/IntegratedTests/UserPermissionTest.cs +++ b/BackEnd/Timeline.Tests/IntegratedTests/UserPermissionTest.cs @@ -115,12 +115,12 @@ namespace Timeline.Tests.IntegratedTests body.Permissions.Should().BeEquivalentTo(UserPermission.AllTimelineManagement.ToString());
}
- await client.TestPutAsync($"users/user1/permissions/{UserPermission.HighlightTimelineManangement}");
+ await client.TestPutAsync($"users/user1/permissions/{UserPermission.HighlightTimelineManagement}");
{
var body = await client.GetUserAsync("user1");
body.Permissions.Should().BeEquivalentTo(UserPermission.AllTimelineManagement.ToString(),
- UserPermission.HighlightTimelineManangement.ToString());
+ UserPermission.HighlightTimelineManagement.ToString());
}
await client.TestPutAsync($"users/user1/permissions/{UserPermission.UserManagement}");
@@ -129,11 +129,11 @@ namespace Timeline.Tests.IntegratedTests var body = await client.GetUserAsync("user1");
body.Permissions.Should().BeEquivalentTo(
UserPermission.AllTimelineManagement.ToString(),
- UserPermission.HighlightTimelineManangement.ToString(),
+ UserPermission.HighlightTimelineManagement.ToString(),
UserPermission.UserManagement.ToString());
}
- await client.TestDeleteAsync($"users/user1/permissions/{UserPermission.HighlightTimelineManangement}");
+ await client.TestDeleteAsync($"users/user1/permissions/{UserPermission.HighlightTimelineManagement}");
{
var body = await client.GetUserAsync("user1");
@@ -149,15 +149,15 @@ namespace Timeline.Tests.IntegratedTests body.Permissions.Should().BeEquivalentTo(UserPermission.UserManagement.ToString());
}
- await client.TestPutAsync($"users/user1/permissions/{UserPermission.HighlightTimelineManangement}");
+ await client.TestPutAsync($"users/user1/permissions/{UserPermission.HighlightTimelineManagement}");
{
var body = await client.GetUserAsync("user1");
body.Permissions.Should().BeEquivalentTo(
- UserPermission.HighlightTimelineManangement.ToString(), UserPermission.UserManagement.ToString());
+ UserPermission.HighlightTimelineManagement.ToString(), UserPermission.UserManagement.ToString());
}
- await client.TestDeleteAsync($"users/user1/permissions/{UserPermission.HighlightTimelineManangement}");
+ await client.TestDeleteAsync($"users/user1/permissions/{UserPermission.HighlightTimelineManagement}");
{
var body = await client.GetUserAsync("user1");
diff --git a/BackEnd/Timeline.Tests/IntegratedTests/UserTest.cs b/BackEnd/Timeline.Tests/IntegratedTests/UserTest.cs index 55a37198..e0ebf635 100644 --- a/BackEnd/Timeline.Tests/IntegratedTests/UserTest.cs +++ b/BackEnd/Timeline.Tests/IntegratedTests/UserTest.cs @@ -29,7 +29,11 @@ namespace Timeline.Tests.IntegratedTests public async Task Get()
{
using var client = await CreateDefaultClient();
- await client.TestGetAsync<UserInfo>($"users/admin");
+ var user = await client.TestGetAsync<UserInfo>($"users/admin");
+ user.Username.Should().Be("admin");
+ user.Nickname.Should().Be("administrator");
+ user.UniqueId.Should().NotBeNullOrEmpty();
+ user.Permissions.Should().NotBeNull();
}
[Fact]
diff --git a/BackEnd/Timeline.Tests/Services/UserPermissionServiceTest.cs b/BackEnd/Timeline.Tests/Services/UserPermissionServiceTest.cs index ea20bd18..f20a7d62 100644 --- a/BackEnd/Timeline.Tests/Services/UserPermissionServiceTest.cs +++ b/BackEnd/Timeline.Tests/Services/UserPermissionServiceTest.cs @@ -62,27 +62,27 @@ namespace Timeline.Tests.Services var permission = await _service.GetPermissionsOfUserAsync(2);
permission.Should().BeEquivalentTo(UserPermission.AllTimelineManagement);
}
- await _service.AddPermissionToUserAsync(2, UserPermission.HighlightTimelineManangement);
+ await _service.AddPermissionToUserAsync(2, UserPermission.HighlightTimelineManagement);
{
var permission = await _service.GetPermissionsOfUserAsync(2);
- permission.Should().BeEquivalentTo(UserPermission.AllTimelineManagement, UserPermission.HighlightTimelineManangement);
+ permission.Should().BeEquivalentTo(UserPermission.AllTimelineManagement, UserPermission.HighlightTimelineManagement);
}
// Add duplicate permission should work.
- await _service.AddPermissionToUserAsync(2, UserPermission.HighlightTimelineManangement);
+ await _service.AddPermissionToUserAsync(2, UserPermission.HighlightTimelineManagement);
{
var permission = await _service.GetPermissionsOfUserAsync(2);
- permission.Should().BeEquivalentTo(UserPermission.AllTimelineManagement, UserPermission.HighlightTimelineManangement);
+ permission.Should().BeEquivalentTo(UserPermission.AllTimelineManagement, UserPermission.HighlightTimelineManagement);
}
- await _service.RemovePermissionFromUserAsync(2, UserPermission.HighlightTimelineManangement);
+ await _service.RemovePermissionFromUserAsync(2, UserPermission.HighlightTimelineManagement);
{
var permission = await _service.GetPermissionsOfUserAsync(2);
permission.Should().BeEquivalentTo(UserPermission.AllTimelineManagement);
}
// Remove non-owned permission should work.
- await _service.RemovePermissionFromUserAsync(2, UserPermission.HighlightTimelineManangement);
+ await _service.RemovePermissionFromUserAsync(2, UserPermission.HighlightTimelineManagement);
{
var permission = await _service.GetPermissionsOfUserAsync(2);
permission.Should().BeEquivalentTo(UserPermission.AllTimelineManagement);
@@ -92,19 +92,19 @@ namespace Timeline.Tests.Services [Fact]
public async Task AddPermissionToInexistentUserShouldThrown()
{
- await _service.Awaiting(s => s.AddPermissionToUserAsync(10, UserPermission.HighlightTimelineManangement)).Should().ThrowAsync<UserNotExistException>();
+ await _service.Awaiting(s => s.AddPermissionToUserAsync(10, UserPermission.HighlightTimelineManagement)).Should().ThrowAsync<UserNotExistException>();
}
[Fact]
public async Task RemovePermissionFromInexistentUserShouldThrown()
{
- await _service.Awaiting(s => s.RemovePermissionFromUserAsync(10, UserPermission.HighlightTimelineManangement)).Should().ThrowAsync<UserNotExistException>();
+ await _service.Awaiting(s => s.RemovePermissionFromUserAsync(10, UserPermission.HighlightTimelineManagement)).Should().ThrowAsync<UserNotExistException>();
}
[Fact]
public async Task RemovePermissionFromInexistentUserShouldNotThrownIfNotCheck()
{
- await _service.Awaiting(s => s.RemovePermissionFromUserAsync(10, UserPermission.HighlightTimelineManangement, false)).Should().NotThrowAsync();
+ await _service.Awaiting(s => s.RemovePermissionFromUserAsync(10, UserPermission.HighlightTimelineManagement, false)).Should().NotThrowAsync();
}
}
}
diff --git a/BackEnd/Timeline/Models/Http/UserInfo.cs b/BackEnd/Timeline/Models/Http/UserInfo.cs index 26b04e90..0f865172 100644 --- a/BackEnd/Timeline/Models/Http/UserInfo.cs +++ b/BackEnd/Timeline/Models/Http/UserInfo.cs @@ -25,10 +25,6 @@ namespace Timeline.Models.Http /// Nickname.
/// </summary>
public string Nickname { get; set; } = default!;
- /// <summary>
- /// True if the user is a administrator.
- /// </summary>
- public bool? Administrator { get; set; } = default!;
#pragma warning disable CA2227 // Collection properties should be read only
/// <summary>
/// The permissions of the user.
diff --git a/BackEnd/Timeline/Models/User.cs b/BackEnd/Timeline/Models/User.cs index 1e90cd1d..ae2afe85 100644 --- a/BackEnd/Timeline/Models/User.cs +++ b/BackEnd/Timeline/Models/User.cs @@ -11,8 +11,6 @@ namespace Timeline.Models public string Username { get; set; } = default!;
public string Nickname { get; set; } = default!;
- [Obsolete("Use permissions instead.")]
- public bool Administrator { get; set; }
public UserPermissions Permissions { get; set; } = default!;
public DateTime UsernameChangeTime { get; set; }
diff --git a/BackEnd/Timeline/Services/UserPermissionService.cs b/BackEnd/Timeline/Services/UserPermissionService.cs index 42c93283..9683000a 100644 --- a/BackEnd/Timeline/Services/UserPermissionService.cs +++ b/BackEnd/Timeline/Services/UserPermissionService.cs @@ -22,7 +22,7 @@ namespace Timeline.Services /// <summary>
/// This permission allow to add or remove highlight timelines.
/// </summary>
- HighlightTimelineManangement
+ HighlightTimelineManagement
}
/// <summary>
diff --git a/BackEnd/Timeline/Services/UserService.cs b/BackEnd/Timeline/Services/UserService.cs index f83d2928..2c5644cd 100644 --- a/BackEnd/Timeline/Services/UserService.cs +++ b/BackEnd/Timeline/Services/UserService.cs @@ -157,7 +157,6 @@ namespace Timeline.Services {
UniqueId = entity.UniqueId,
Username = entity.Username,
- Administrator = permission.Contains(UserPermission.UserManagement),
Permissions = permission,
Nickname = string.IsNullOrEmpty(entity.Nickname) ? entity.Username : entity.Nickname,
Id = entity.Id,
diff --git a/FrontEnd/src/app/App.tsx b/FrontEnd/src/app/App.tsx index 01b1883b..6cdf2434 100644 --- a/FrontEnd/src/app/App.tsx +++ b/FrontEnd/src/app/App.tsx @@ -58,7 +58,7 @@ const App: React.FC = () => { <Route path="/users/:username"> <User /> </Route> - {user && user.administrator && ( + {user && user.hasAdministrationPermission && ( <Route path="/admin"> <LazyAdmin user={user} /> </Route> diff --git a/FrontEnd/src/app/http/user.ts b/FrontEnd/src/app/http/user.ts index a0a02cce..929956d0 100644 --- a/FrontEnd/src/app/http/user.ts +++ b/FrontEnd/src/app/http/user.ts @@ -12,14 +12,28 @@ import { convertToNotModified, } from "./common"; +export const kUserManagement = "UserManagement"; +export const kAllTimelineManagement = "AllTimelineManagement"; +export const kHighlightTimelineManagement = "HighlightTimelineManagement"; + +export const kUserPermissionList = [ + kUserManagement, + kAllTimelineManagement, + kHighlightTimelineManagement, +] as const; + +export type UserPermission = typeof kUserPermissionList[number]; + export interface HttpUser { uniqueId: string; username: string; - administrator: boolean; + permissions: UserPermission[]; nickname: string; } export interface HttpUserPatchRequest { + username?: string; + password?: string; nickname?: string; } @@ -28,6 +42,11 @@ export interface HttpChangePasswordRequest { newPassword: string; } +export interface HttpCreateUserRequest { + username: string; + password: string; +} + export class HttpUserNotExistError extends Error { constructor(public innerError?: AxiosError) { super(); @@ -41,12 +60,14 @@ export class HttpChangePasswordBadCredentialError extends Error { } export interface IHttpUserClient { + list(): Promise<HttpUser[]>; get(username: string): Promise<HttpUser>; patch( username: string, req: HttpUserPatchRequest, token: string ): Promise<HttpUser>; + delete(username: string, token: string): Promise<void>; getAvatar(username: string): Promise<BlobWithEtag>; getAvatar( username: string, @@ -54,9 +75,28 @@ export interface IHttpUserClient { ): Promise<BlobWithEtag | NotModified>; putAvatar(username: string, data: Blob, token: string): Promise<void>; changePassword(req: HttpChangePasswordRequest, token: string): Promise<void>; + putUserPermission( + username: string, + permission: UserPermission, + token: string + ): Promise<void>; + deleteUserPermission( + username: string, + permission: UserPermission, + token: string + ): Promise<void>; + + createUser(req: HttpCreateUserRequest, token: string): Promise<HttpUser>; } export class HttpUserClient implements IHttpUserClient { + list(): Promise<HttpUser[]> { + return axios + .get<HttpUser[]>(`${apiBaseUrl}/users`) + .then(extractResponseData) + .catch(convertToNetworkError); + } + get(username: string): Promise<HttpUser> { return axios .get<HttpUser>(`${apiBaseUrl}/users/${username}`) @@ -76,6 +116,13 @@ export class HttpUserClient implements IHttpUserClient { .catch(convertToNetworkError); } + delete(username: string, token: string): Promise<void> { + return axios + .delete(`${apiBaseUrl}/users/${username}?token=${token}`) + .catch(convertToNetworkError) + .then(); + } + getAvatar(username: string): Promise<BlobWithEtag>; getAvatar( username: string, @@ -119,6 +166,40 @@ export class HttpUserClient implements IHttpUserClient { .catch(convertToNetworkError) .then(); } + + putUserPermission( + username: string, + permission: UserPermission, + token: string + ): Promise<void> { + return axios + .put( + `${apiBaseUrl}/users/${username}/permissions/${permission}?token=${token}` + ) + .catch(convertToNetworkError) + .then(); + } + + deleteUserPermission( + username: string, + permission: UserPermission, + token: string + ): Promise<void> { + return axios + .delete( + `${apiBaseUrl}/users/${username}/permissions/${permission}?token=${token}` + ) + .catch(convertToNetworkError) + .then(); + } + + createUser(req: HttpCreateUserRequest, token: string): Promise<HttpUser> { + return axios + .post<HttpUser>(`${apiBaseUrl}/userop/createuser?token=${token}`, req) + .then(extractResponseData) + .catch(convertToNetworkError) + .then(); + } } let client: IHttpUserClient = new HttpUserClient(); diff --git a/FrontEnd/src/app/index.sass b/FrontEnd/src/app/index.sass index b8cc464e..d5e1ea22 100644 --- a/FrontEnd/src/app/index.sass +++ b/FrontEnd/src/app/index.sass @@ -10,6 +10,8 @@ @import './views/timeline/timeline'
@import './views/user/user'
+@import './views/admin/admin'
+
body
margin: 0
@@ -63,3 +65,12 @@ textarea .text-orange
color: $orange
+
+@each $color, $value in $theme-colors
+ .text-button
+ background: transparent
+ border: none
+ &.#{$color}
+ color: $value
+ &:hover
+ color: adjust-color($value, $lightness: +15%)
diff --git a/FrontEnd/src/app/locales/en/admin.json b/FrontEnd/src/app/locales/en/admin.json index 69a88e3b..098ffb1f 100644 --- a/FrontEnd/src/app/locales/en/admin.json +++ b/FrontEnd/src/app/locales/en/admin.json @@ -1 +1,35 @@ -{}
+{
+ "nav": {
+ "users": "Users",
+ "highlightTimelines": "Highlight Timelines"
+ },
+ "create": "Create",
+ "user": {
+ "username": "Username: ",
+ "password": "Password: ",
+ "nickname": "Nickname: ",
+ "uniqueId": "Unique ID: ",
+ "permissions": "Permissions: ",
+ "modify": "Modify",
+ "modifyPermissions": "Modify Permissions",
+ "delete": "Delete",
+ "dialog": {
+ "create": {
+ "title": "Create User",
+ "prompt": "You are creating a new user."
+ },
+ "delete": {
+ "title": "Delete user",
+ "prompt": "You are deleting <1>username</1> . Caution: This can't be undo."
+ },
+ "modify": {
+ "title": "Modify User",
+ "prompt": "You are modifying user <1>username</1> ."
+ },
+ "modifyPermissions": {
+ "title": "Modify User Permissions",
+ "prompt": "You are modifying permissions of user <1>username</1> ."
+ }
+ }
+ }
+}
diff --git a/FrontEnd/src/app/locales/en/translation.json b/FrontEnd/src/app/locales/en/translation.json index 662a1aac..cdb6da37 100644 --- a/FrontEnd/src/app/locales/en/translation.json +++ b/FrontEnd/src/app/locales/en/translation.json @@ -13,7 +13,8 @@ "nav": { "settings": "Settings", "login": "Login", - "about": "About" + "about": "About", + "administration": "Administration" }, "chooseImage": "Choose a image", "loadImageError": "Failed to load image.", diff --git a/FrontEnd/src/app/locales/zh/admin.json b/FrontEnd/src/app/locales/zh/admin.json index 69a88e3b..fed39b2d 100644 --- a/FrontEnd/src/app/locales/zh/admin.json +++ b/FrontEnd/src/app/locales/zh/admin.json @@ -1 +1,35 @@ -{}
+{
+ "nav": {
+ "users": "用户",
+ "highlightTimelines": "高光时间线"
+ },
+ "create": "创建",
+ "user": {
+ "username": "用户名:",
+ "password": "密码:",
+ "nickname": "昵称:",
+ "uniqueId": "唯一ID:",
+ "permissions": "权限:",
+ "modify": "修改",
+ "modifyPermissions": "修改权限",
+ "delete": "删除",
+ "dialog": {
+ "create": {
+ "title": "创建用户",
+ "prompt": "您正在创建一个新用户。"
+ },
+ "delete": {
+ "title": "删除用户",
+ "prompt": "您正在删除用户 <1>username</1> 。注意:此操作不可撤销。"
+ },
+ "modify": {
+ "title": "修改用户",
+ "prompt": "您正在修改用户 <1>username</1> 。"
+ },
+ "modifyPermissions": {
+ "title": "修改用户权限",
+ "prompt": "您正在修改用户 <1>username</1> 的权限。"
+ }
+ }
+ }
+}
diff --git a/FrontEnd/src/app/locales/zh/translation.json b/FrontEnd/src/app/locales/zh/translation.json index ecd1df4b..5d28f694 100644 --- a/FrontEnd/src/app/locales/zh/translation.json +++ b/FrontEnd/src/app/locales/zh/translation.json @@ -13,7 +13,8 @@ "nav": { "settings": "设置", "login": "登陆", - "about": "关于" + "about": "关于", + "administration": "管理" }, "chooseImage": "选择一个图片", "loadImageError": "加载图片失败", diff --git a/FrontEnd/src/app/services/timeline.ts b/FrontEnd/src/app/services/timeline.ts index 2cbbffab..c58516fc 100644 --- a/FrontEnd/src/app/services/timeline.ts +++ b/FrontEnd/src/app/services/timeline.ts @@ -29,11 +29,11 @@ export type { TimelineVisibility } from "@/http/timeline"; import { dataStorage, throwIfNotNetworkError, BlobOrStatus } from "./common"; import { DataHub, WithSyncStatus } from "./DataHub"; import { - UserAuthInfo, checkLogin, userService, userInfoService, User, + AuthUser, } from "./user"; export type TimelineInfo = HttpTimelineInfo; @@ -608,10 +608,11 @@ export class TimelineService { } hasReadPermission( - user: UserAuthInfo | null | undefined, + user: AuthUser | null | undefined, timeline: TimelineInfo ): boolean { - if (user != null && user.administrator) return true; + if (user != null && user.hasAllTimelineAdministrationPermission) + return true; const { visibility } = timeline; if (visibility === "Public") { @@ -631,10 +632,11 @@ export class TimelineService { } hasPostPermission( - user: UserAuthInfo | null | undefined, + user: AuthUser | null | undefined, timeline: TimelineInfo ): boolean { - if (user != null && user.administrator) return true; + if (user != null && user.hasAllTimelineAdministrationPermission) + return true; return ( user != null && @@ -644,20 +646,22 @@ export class TimelineService { } hasManagePermission( - user: UserAuthInfo | null | undefined, + user: AuthUser | null | undefined, timeline: TimelineInfo ): boolean { - if (user != null && user.administrator) return true; + if (user != null && user.hasAllTimelineAdministrationPermission) + return true; return user != null && user.username == timeline.owner.username; } hasModifyPostPermission( - user: UserAuthInfo | null | undefined, + user: AuthUser | null | undefined, timeline: TimelineInfo, post: TimelinePostInfo ): boolean { - if (user != null && user.administrator) return true; + if (user != null && user.hasAllTimelineAdministrationPermission) + return true; return ( user != null && diff --git a/FrontEnd/src/app/services/user.ts b/FrontEnd/src/app/services/user.ts index cd6d1c15..0166bce0 100644 --- a/FrontEnd/src/app/services/user.ts +++ b/FrontEnd/src/app/services/user.ts @@ -14,6 +14,7 @@ import { getHttpUserClient, HttpUserNotExistError, HttpUser, + UserPermission, } from "@/http/user"; import { dataStorage, throwIfNotNetworkError } from "./common"; @@ -22,13 +23,26 @@ import { pushAlert } from "./alert"; export type User = HttpUser; -export interface UserAuthInfo { +export class AuthUser implements User { + constructor(user: User, public token: string) { + this.uniqueId = user.uniqueId; + this.username = user.username; + this.permissions = user.permissions; + this.nickname = user.nickname; + } + + uniqueId: string; username: string; - administrator: boolean; -} + permissions: UserPermission[]; + nickname: string; -export interface UserWithToken extends User { - token: string; + get hasAdministrationPermission(): boolean { + return this.permissions.length !== 0; + } + + get hasAllTimelineAdministrationPermission(): boolean { + return this.permissions.includes("AllTimelineManagement"); + } } export interface LoginCredentials { @@ -43,24 +57,24 @@ export class BadCredentialError { const USER_STORAGE_KEY = "currentuser"; export class UserService { - private userSubject = new BehaviorSubject<UserWithToken | null | undefined>( + private userSubject = new BehaviorSubject<AuthUser | null | undefined>( undefined ); - get user$(): Observable<UserWithToken | null | undefined> { + get user$(): Observable<AuthUser | null | undefined> { return this.userSubject; } - get currentUser(): UserWithToken | null | undefined { + get currentUser(): AuthUser | null | undefined { return this.userSubject.value; } - async checkLoginState(): Promise<UserWithToken | null> { + async checkLoginState(): Promise<AuthUser | null> { if (this.currentUser !== undefined) { console.warn("Already checked user. Can't check twice."); } - const savedUser = await dataStorage.getItem<UserWithToken | null>( + const savedUser = await dataStorage.getItem<AuthUser | null>( USER_STORAGE_KEY ); @@ -74,8 +88,8 @@ export class UserService { const savedToken = savedUser.token; try { const res = await getHttpTokenClient().verify({ token: savedToken }); - const user: UserWithToken = { ...res.user, token: savedToken }; - await dataStorage.setItem<UserWithToken>(USER_STORAGE_KEY, user); + const user = new AuthUser(res.user, savedToken); + await dataStorage.setItem<AuthUser>(USER_STORAGE_KEY, user); this.userSubject.next(user); pushAlert({ type: "success", @@ -116,12 +130,9 @@ export class UserService { ...credentials, expire: 30, }); - const user: UserWithToken = { - ...res.user, - token: res.token, - }; + const user = new AuthUser(res.user, res.token); if (rememberMe) { - await dataStorage.setItem<UserWithToken>(USER_STORAGE_KEY, user); + await dataStorage.setItem<AuthUser>(USER_STORAGE_KEY, user); } this.userSubject.next(user); } catch (e) { @@ -169,8 +180,8 @@ export class UserService { export const userService = new UserService(); -export function useRawUser(): UserWithToken | null | undefined { - const [user, setUser] = useState<UserWithToken | null | undefined>( +export function useRawUser(): AuthUser | null | undefined { + const [user, setUser] = useState<AuthUser | null | undefined>( userService.currentUser ); useEffect(() => { @@ -182,8 +193,8 @@ export function useRawUser(): UserWithToken | null | undefined { return user; } -export function useUser(): UserWithToken | null { - const [user, setUser] = useState<UserWithToken | null>(() => { +export function useUser(): AuthUser | null { + const [user, setUser] = useState<AuthUser | null>(() => { const initUser = userService.currentUser; if (initUser === undefined) { throw new UiLogicError( @@ -208,7 +219,7 @@ export function useUser(): UserWithToken | null { return user; } -export function useUserLoggedIn(): UserWithToken { +export function useUserLoggedIn(): AuthUser { const user = useUser(); if (user == null) { throw new UiLogicError("You assert user has logged in but actually not."); @@ -216,7 +227,7 @@ export function useUserLoggedIn(): UserWithToken { return user; } -export function checkLogin(): UserWithToken { +export function checkLogin(): AuthUser { const user = userService.currentUser; if (user == null) { throw new UiLogicError("You must login to perform the operation."); diff --git a/FrontEnd/src/app/views/admin/Admin.tsx b/FrontEnd/src/app/views/admin/Admin.tsx index 9c0250e7..446cd36d 100644 --- a/FrontEnd/src/app/views/admin/Admin.tsx +++ b/FrontEnd/src/app/views/admin/Admin.tsx @@ -1,72 +1,45 @@ import React, { Fragment } from "react"; -import { - Redirect, - Route, - Switch, - useRouteMatch, - useHistory, -} from "react-router"; -import { Nav } from "react-bootstrap"; +import { Redirect, Route, Switch, useRouteMatch, match } from "react-router"; +import { Container } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; -import { UserWithToken } from "@/services/user"; +import { AuthUser } from "@/services/user"; +import AdminNav from "./AdminNav"; import UserAdmin from "./UserAdmin"; +import HighlightTimelineAdmin from "./HighlightTimelineAdmin"; interface AdminProps { - user: UserWithToken; + user: AuthUser; } -const Admin: React.FC<AdminProps> = (props) => { - const match = useRouteMatch(); - const history = useHistory(); - type TabNames = "users" | "more"; - - const tabName = history.location.pathname.replace(match.path + "/", ""); +const Admin: React.FC<AdminProps> = ({ user }) => { + useTranslation("admin"); - function toggle(newTab: TabNames): void { - history.push(`${match.url}/${newTab}`); - } - - const createRoute = ( - name: string, - body: React.ReactNode - ): React.ReactNode => { - return ( - <Route path={`${match.path}/${name}`}> - <div style={{ height: 56 }} className="flex-fix-length" /> - <Nav variant="tabs"> - <Nav.Item> - <Nav.Link - active={tabName === "users"} - onClick={() => { - toggle("users"); - }} - > - Users - </Nav.Link> - </Nav.Item> - <Nav.Item> - <Nav.Link - active={tabName === "more"} - onClick={() => { - toggle("more"); - }} - > - More - </Nav.Link> - </Nav.Item> - </Nav> - {body} - </Route> - ); - }; + const match = useRouteMatch(); return ( <Fragment> <Switch> <Redirect from={match.path} to={`${match.path}/users`} exact /> - {createRoute("users", <UserAdmin user={props.user} />)} - {createRoute("more", <div>More Page Works</div>)} + <Route path={`${match.path}/:name`}> + {(p) => { + const match = p.match as match<{ name: string }>; + const name = match.params["name"]; + return ( + <Container> + <AdminNav /> + {(() => { + if (name === "users") { + return <UserAdmin user={user} />; + } else if (name === "highlighttimelines") { + return <HighlightTimelineAdmin user={user} />; + } + })()} + </Container> + ); + }} + </Route> </Switch> </Fragment> ); diff --git a/FrontEnd/src/app/views/admin/AdminNav.tsx b/FrontEnd/src/app/views/admin/AdminNav.tsx new file mode 100644 index 00000000..f376beda --- /dev/null +++ b/FrontEnd/src/app/views/admin/AdminNav.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { Nav } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; +import { useHistory, useRouteMatch } from "react-router"; + +const AdminNav: React.FC = () => { + const match = useRouteMatch<{ name: string }>(); + const history = useHistory(); + + const { t } = useTranslation(); + + const name = match.params.name; + + function toggle(newTab: string): void { + history.push(`/admin/${newTab}`); + } + + return ( + <Nav variant="tabs" className="my-2"> + <Nav.Item> + <Nav.Link + active={name === "users"} + onClick={() => { + toggle("users"); + }} + > + {t("admin:nav.users")} + </Nav.Link> + </Nav.Item> + <Nav.Item> + <Nav.Link + active={name === "highlighttimelines"} + onClick={() => { + toggle("highlighttimelines"); + }} + > + {t("admin:nav.highlightTimelines")} + </Nav.Link> + </Nav.Item> + </Nav> + ); +}; + +export default AdminNav; diff --git a/FrontEnd/src/app/views/admin/HighlightTimelineAdmin.tsx b/FrontEnd/src/app/views/admin/HighlightTimelineAdmin.tsx new file mode 100644 index 00000000..3de7d5a6 --- /dev/null +++ b/FrontEnd/src/app/views/admin/HighlightTimelineAdmin.tsx @@ -0,0 +1,13 @@ +import React from "react"; + +import { AuthUser } from "@/services/user"; + +export interface HighlightTimelineAdminProps { + user: AuthUser; +} + +const HighlightTimelineAdmin: React.FC<HighlightTimelineAdminProps> = () => { + return <>This is highlight timeline administration page.</>; +}; + +export default HighlightTimelineAdmin; diff --git a/FrontEnd/src/app/views/admin/UserAdmin.tsx b/FrontEnd/src/app/views/admin/UserAdmin.tsx index 0f5f8796..948cbb25 100644 --- a/FrontEnd/src/app/views/admin/UserAdmin.tsx +++ b/FrontEnd/src/app/views/admin/UserAdmin.tsx @@ -1,173 +1,59 @@ import React, { useState, useEffect } from "react"; -import axios from "axios"; -import { - ListGroup, - Row, - Col, - Dropdown, - Spinner, - Button, -} from "react-bootstrap"; - -import OperationDialog from "../common/OperationDialog"; -import { User, UserWithToken } from "@/services/user"; - -const apiBaseUrl = "/api"; - -async function fetchUserList(_token: string): Promise<User[]> { - const res = await axios.get<User[]>(`${apiBaseUrl}/users`); - return res.data; -} - -interface CreateUserInfo { - username: string; - password: string; - administrator: boolean; -} +import clsx from "clsx"; +import { ListGroup, Row, Col, Spinner, Button } from "react-bootstrap"; +import InlineSVG from "react-inlinesvg"; +import PencilSquareIcon from "bootstrap-icons/icons/pencil-square.svg"; -async function createUser(user: CreateUserInfo, token: string): Promise<User> { - const res = await axios.post<User>( - `${apiBaseUrl}/userop/createuser?token=${token}`, - user - ); - return res.data; -} - -function deleteUser(username: string, token: string): Promise<void> { - return axios.delete(`${apiBaseUrl}/users/${username}?token=${token}`); -} - -function changeUsername( - oldUsername: string, - newUsername: string, - token: string -): Promise<void> { - return axios.patch(`${apiBaseUrl}/users/${oldUsername}?token=${token}`, { - username: newUsername, - }); -} - -function changePassword( - username: string, - newPassword: string, - token: string -): Promise<void> { - return axios.patch(`${apiBaseUrl}/users/${username}?token=${token}`, { - password: newPassword, - }); -} - -function changePermission( - username: string, - newPermission: boolean, - token: string -): Promise<void> { - return axios.patch(`${apiBaseUrl}/users/${username}?token=${token}`, { - administrator: newPermission, - }); -} - -const kChangeUsername = "changeusername"; -const kChangePassword = "changepassword"; -const kChangePermission = "changepermission"; -const kDelete = "delete"; - -type TChangeUsername = typeof kChangeUsername; -type TChangePassword = typeof kChangePassword; -type TChangePermission = typeof kChangePermission; -type TDelete = typeof kDelete; - -type ContextMenuItem = - | TChangeUsername - | TChangePassword - | TChangePermission - | TDelete; - -interface UserCardProps { - onContextMenu: (item: ContextMenuItem) => void; - user: User; -} +import OperationDialog, { + OperationBoolInputInfo, +} from "../common/OperationDialog"; -const UserItem: React.FC<UserCardProps> = (props) => { - const user = props.user; - - const createClickCallback = (item: ContextMenuItem): (() => void) => { - return () => { - props.onContextMenu(item); - }; - }; - - return ( - <ListGroup.Item className="container"> - <Row className="align-items-center"> - <Col> - <p className="mb-0 text-primary">{user.username}</p> - <small - className={user.administrator ? "text-danger" : "text-secondary"} - > - {user.administrator ? "administrator" : "user"} - </small> - </Col> - <Col className="col-auto"> - <Dropdown> - <Dropdown.Toggle variant="warning" className="text-light"> - Manage - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item onClick={createClickCallback(kChangeUsername)}> - Change Username - </Dropdown.Item> - <Dropdown.Item onClick={createClickCallback(kChangePassword)}> - Change Password - </Dropdown.Item> - <Dropdown.Item onClick={createClickCallback(kChangePermission)}> - Change Permission - </Dropdown.Item> - <Dropdown.Item - className="text-danger" - onClick={createClickCallback(kDelete)} - > - Delete - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> - </Col> - </Row> - </ListGroup.Item> - ); -}; - -interface DialogProps { +import { User, AuthUser } from "@/services/user"; +import { + getHttpUserClient, + HttpUser, + kUserPermissionList, + UserPermission, +} from "@/http/user"; +import { Trans, useTranslation } from "react-i18next"; + +interface DialogProps<TData = undefined, TReturn = undefined> { open: boolean; close: () => void; + token: string; + data: TData; + onSuccess: (data: TReturn) => void; } -interface CreateUserDialogProps extends DialogProps { - process: (user: CreateUserInfo) => Promise<void>; -} - -const CreateUserDialog: React.FC<CreateUserDialogProps> = (props) => { +const CreateUserDialog: React.FC<DialogProps<undefined, HttpUser>> = ({ + open, + close, + token, + onSuccess, +}) => { return ( <OperationDialog - title="Create" - titleColor="create" - inputPrompt="You are creating a new user." + title="admin:user.dialog.create.title" + themeColor="success" + inputPrompt="admin:user.dialog.create.prompt" inputScheme={ [ - { type: "text", label: "Username" }, - { type: "text", label: "Password" }, - { type: "bool", label: "Administrator" }, + { type: "text", label: "admin:user.username" }, + { type: "text", label: "admin:user.password" }, ] as const } - onProcess={([username, password, administrator]) => - props.process({ - username: username, - password: password, - administrator: administrator, - }) + onProcess={([username, password]) => + getHttpUserClient().createUser( + { + username, + password, + }, + token + ) } - close={props.close} - open={props.open} + close={close} + open={open} + onSuccessAndClose={onSuccess} /> ); }; @@ -176,242 +62,301 @@ const UsernameLabel: React.FC = (props) => { return <span style={{ color: "blue" }}>{props.children}</span>; }; -interface UserDeleteDialogProps extends DialogProps { - username: string; - process: () => Promise<void>; -} - -const UserDeleteDialog: React.FC<UserDeleteDialogProps> = (props) => { +const UserDeleteDialog: React.FC<DialogProps< + { username: string }, + unknown +>> = ({ open, close, token, data: { username }, onSuccess }) => { return ( <OperationDialog - open={props.open} - close={props.close} - title="Dangerous" - titleColor="dangerous" + open={open} + close={close} + title="admin:user.dialog.delete.title" + themeColor="danger" inputPrompt={() => ( - <> - {"You are deleting user "} - <UsernameLabel>{props.username}</UsernameLabel> - {" !"} - </> + <Trans i18nKey="admin:user.dialog.delete.prompt"> + 0<UsernameLabel>{username}</UsernameLabel>2 + </Trans> )} - onProcess={props.process} + onProcess={() => getHttpUserClient().delete(username, token)} + onSuccessAndClose={onSuccess} /> ); }; -interface UserModifyDialogProps<T> extends DialogProps { - username: string; - process: (value: T) => Promise<void>; -} - -const UserChangeUsernameDialog: React.FC<UserModifyDialogProps<string>> = ( - props -) => { +const UserModifyDialog: React.FC<DialogProps< + { + oldUser: HttpUser; + }, + HttpUser +>> = ({ open, close, token, data: { oldUser }, onSuccess }) => { return ( <OperationDialog - open={props.open} - close={props.close} - title="Caution" - titleColor="dangerous" + open={open} + close={close} + title="admin:user.dialog.modify.title" + themeColor="danger" inputPrompt={() => ( - <> - {"You are change the username of user "} - <UsernameLabel>{props.username}</UsernameLabel> - {" !"} - </> + <Trans i18nKey="admin:user.dialog.modify.prompt"> + 0<UsernameLabel>{oldUser.username}</UsernameLabel>2 + </Trans> )} - inputScheme={[{ type: "text", label: "New Username" }]} - onProcess={([newUsername]) => { - return props.process(newUsername); - }} + inputScheme={ + [ + { + type: "text", + label: "admin:user.username", + initValue: oldUser.username, + }, + { type: "text", label: "admin:user.password" }, + { + type: "text", + label: "admin:user.nickname", + initValue: oldUser.nickname, + }, + ] as const + } + onProcess={([username, password, nickname]) => + getHttpUserClient().patch( + oldUser.username, + { + username: username !== oldUser.username ? username : undefined, + password: password !== "" ? password : undefined, + nickname: nickname !== oldUser.nickname ? nickname : undefined, + }, + token + ) + } + onSuccessAndClose={onSuccess} /> ); }; -const UserChangePasswordDialog: React.FC<UserModifyDialogProps<string>> = ( - props -) => { +const UserPermissionModifyDialog: React.FC<DialogProps< + { + username: string; + permissions: UserPermission[]; + }, + UserPermission[] +>> = ({ open, close, token, data: { username, permissions }, onSuccess }) => { + const oldPermissionBoolList: boolean[] = kUserPermissionList.map( + (permission) => permissions.includes(permission) + ); + return ( <OperationDialog - open={props.open} - close={props.close} - title="Caution" - titleColor="dangerous" + open={open} + close={close} + title="admin:user.dialog.modifyPermissions.title" + themeColor="danger" inputPrompt={() => ( - <> - {"You are change the password of user "} - <UsernameLabel>{props.username}</UsernameLabel> - {" !"} - </> + <Trans i18nKey="admin:user.dialog.modifyPermissions.prompt"> + 0<UsernameLabel>{username}</UsernameLabel>2 + </Trans> + )} + inputScheme={kUserPermissionList.map<OperationBoolInputInfo>( + (permission, index) => ({ + type: "bool", + label: permission, + initValue: oldPermissionBoolList[index], + }) )} - inputScheme={[{ type: "text", label: "New Password" }]} - onProcess={([newPassword]) => { - return props.process(newPassword); + onProcess={async (newPermissionBoolList): Promise<boolean[]> => { + for (let index = 0; index < kUserPermissionList.length; index++) { + const oldValue = oldPermissionBoolList[index]; + const newValue = newPermissionBoolList[index]; + const permission = kUserPermissionList[index]; + if (oldValue === newValue) continue; + if (newValue) { + await getHttpUserClient().putUserPermission( + username, + permission, + token + ); + } else { + await getHttpUserClient().deleteUserPermission( + username, + permission, + token + ); + } + } + return newPermissionBoolList; + }} + onSuccessAndClose={(newPermissionBoolList: boolean[]) => { + const permissions: UserPermission[] = []; + for (let index = 0; index < kUserPermissionList.length; index++) { + if (newPermissionBoolList[index]) { + permissions.push(kUserPermissionList[index]); + } + } + onSuccess(permissions); }} /> ); }; -interface UserChangePermissionDialogProps extends DialogProps { - username: string; - newPermission: boolean; - process: () => Promise<void>; +const kModify = "modify"; +const kModifyPermission = "permission"; +const kDelete = "delete"; + +type TModify = typeof kModify; +type TModifyPermission = typeof kModifyPermission; +type TDelete = typeof kDelete; + +type ContextMenuItem = TModify | TModifyPermission | TDelete; + +interface UserItemProps { + on: { [key in ContextMenuItem]: () => void }; + user: User; } -const UserChangePermissionDialog: React.FC<UserChangePermissionDialogProps> = ( - props -) => { +const UserItem: React.FC<UserItemProps> = ({ user, on }) => { + const { t } = useTranslation(); + + const [editMaskVisible, setEditMaskVisible] = React.useState<boolean>(false); + return ( - <OperationDialog - open={props.open} - close={props.close} - title="Caution" - titleColor="dangerous" - inputPrompt={() => ( - <> - {"You are change user "} - <UsernameLabel>{props.username}</UsernameLabel> - {" to "} - <span style={{ color: "orange" }}> - {props.newPermission ? "administrator" : "normal user"} - </span> - {" !"} - </> - )} - onProcess={props.process} - /> + <ListGroup.Item className="admin-user-item"> + <InlineSVG + src={PencilSquareIcon} + className="float-right icon-button text-warning" + onClick={() => setEditMaskVisible(true)} + /> + <h4 className="text-primary">{user.username}</h4> + <div className="text-secondary"> + {t("admin:user.nickname")} + {user.nickname} + </div> + <div className="text-secondary"> + {t("admin:user.uniqueId")} + {user.uniqueId} + </div> + <div className="text-secondary"> + {t("admin:user.permissions")} + {user.permissions.map((permission) => { + return ( + <span key={permission} className="text-danger"> + {permission}{" "} + </span> + ); + })} + </div> + <div + className={clsx("edit-mask", !editMaskVisible && "d-none")} + onClick={() => setEditMaskVisible(false)} + > + <button className="text-button primary" onClick={on[kModify]}> + {t("admin:user.modify")} + </button> + <button className="text-button primary" onClick={on[kModifyPermission]}> + {t("admin:user.modifyPermissions")} + </button> + <button className="text-button danger" onClick={on[kDelete]}> + {t("admin:user.delete")} + </button> + </div> + </ListGroup.Item> ); }; interface UserAdminProps { - user: UserWithToken; + user: AuthUser; } const UserAdmin: React.FC<UserAdminProps> = (props) => { + const { t } = useTranslation(); + type DialogInfo = | null | { type: "create"; } - | { type: TDelete; username: string } | { - type: TChangeUsername; - username: string; + type: TModify; + user: HttpUser; } | { - type: TChangePassword; + type: TModifyPermission; username: string; + permissions: UserPermission[]; } - | { - type: TChangePermission; - username: string; - newPermission: boolean; - }; + | { type: TDelete; username: string }; const [users, setUsers] = useState<User[] | null>(null); const [dialog, setDialog] = useState<DialogInfo>(null); + const [usersVersion, setUsersVersion] = useState<number>(0); + const updateUsers = (): void => { + setUsersVersion(usersVersion + 1); + }; const token = props.user.token; useEffect(() => { let subscribe = true; - void fetchUserList(props.user.token).then((us) => { - if (subscribe) { - setUsers(us); - } - }); + void getHttpUserClient() + .list() + .then((us) => { + if (subscribe) { + setUsers(us); + } + }); return () => { subscribe = false; }; - }, [props.user]); + }, [usersVersion]); let dialogNode: React.ReactNode; - if (dialog) + if (dialog) { switch (dialog.type) { case "create": dialogNode = ( <CreateUserDialog open close={() => setDialog(null)} - process={async (user) => { - const u = await createUser(user, token); - setUsers((oldUsers) => [...(oldUsers ?? []), u]); - }} + token={token} + data={undefined} + onSuccess={updateUsers} /> ); break; - case "delete": + case kDelete: dialogNode = ( <UserDeleteDialog open close={() => setDialog(null)} - username={dialog.username} - process={async () => { - await deleteUser(dialog.username, token); - setUsers((oldUsers) => - (oldUsers ?? []).filter((u) => u.username !== dialog.username) - ); - }} - /> - ); - break; - case kChangeUsername: - dialogNode = ( - <UserChangeUsernameDialog - open - close={() => setDialog(null)} - username={dialog.username} - process={async (newUsername) => { - await changeUsername(dialog.username, newUsername, token); - setUsers((oldUsers) => { - const users = (oldUsers ?? []).slice(); - const findedUser = users.find( - (u) => u.username === dialog.username - ); - if (findedUser) findedUser.username = newUsername; - return users; - }); - }} + token={token} + data={{ username: dialog.username }} + onSuccess={updateUsers} /> ); break; - case kChangePassword: + case kModify: dialogNode = ( - <UserChangePasswordDialog + <UserModifyDialog open close={() => setDialog(null)} - username={dialog.username} - process={async (newPassword) => { - await changePassword(dialog.username, newPassword, token); - }} + token={token} + data={{ oldUser: dialog.user }} + onSuccess={updateUsers} /> ); break; - case kChangePermission: { - const newPermission = dialog.newPermission; + case kModifyPermission: dialogNode = ( - <UserChangePermissionDialog + <UserPermissionModifyDialog open close={() => setDialog(null)} - username={dialog.username} - newPermission={newPermission} - process={async () => { - await changePermission(dialog.username, newPermission, token); - setUsers((oldUsers) => { - const users = (oldUsers ?? []).slice(); - const findedUser = users.find( - (u) => u.username === dialog.username - ); - if (findedUser) findedUser.administrator = newPermission; - return users; - }); + token={token} + data={{ + username: dialog.username, + permissions: dialog.permissions, }} + onSuccess={updateUsers} /> ); break; - } } + } if (users) { const userComponents = users.map((user) => { @@ -419,19 +364,26 @@ const UserAdmin: React.FC<UserAdminProps> = (props) => { <UserItem key={user.username} user={user} - onContextMenu={(item) => { - setDialog( - item === kChangePermission - ? { - type: kChangePermission, - username: user.username, - newPermission: !user.administrator, - } - : { - type: item, - username: user.username, - } - ); + on={{ + modify: () => { + setDialog({ + type: "modify", + user, + }); + }, + permission: () => { + setDialog({ + type: kModifyPermission, + username: user.username, + permissions: user.permissions, + }); + }, + delete: () => { + setDialog({ + type: "delete", + username: user.username, + }); + }, }} /> ); @@ -439,17 +391,20 @@ const UserAdmin: React.FC<UserAdminProps> = (props) => { return ( <> - <Button - variant="success" - onClick={() => - setDialog({ - type: "create", - }) - } - className="align-self-end" - > - Create User - </Button> + <Row className="justify-content-end my-2"> + <Col xs="auto"> + <Button + variant="outline-success" + onClick={() => + setDialog({ + type: "create", + }) + } + > + {t("admin:create")} + </Button> + </Col> + </Row> {userComponents} {dialogNode} </> diff --git a/FrontEnd/src/app/views/admin/admin.sass b/FrontEnd/src/app/views/admin/admin.sass new file mode 100644 index 00000000..1ce010f8 --- /dev/null +++ b/FrontEnd/src/app/views/admin/admin.sass @@ -0,0 +1,22 @@ +.admin-user-item
+ position: relative
+
+ .edit-mask
+ position: absolute
+ top: 0
+ left: 0
+ bottom: 0
+ right: 0
+
+ background: #ffffffc5
+ position: absolute
+
+ display: flex
+ justify-content: center
+ align-items: center
+
+ @include media-breakpoint-down(xs)
+ flex-direction: column
+
+ button
+ margin: 0.5em 2em
diff --git a/FrontEnd/src/app/views/common/AppBar.tsx b/FrontEnd/src/app/views/common/AppBar.tsx index 8f35b482..c862a6d3 100644 --- a/FrontEnd/src/app/views/common/AppBar.tsx +++ b/FrontEnd/src/app/views/common/AppBar.tsx @@ -15,7 +15,7 @@ const AppBar: React.FC = (_) => { const { t } = useTranslation(); - const isAdministrator = user && user.administrator; + const hasAdministrationPermission = user && user.hasAdministrationPermission; const [expand, setExpand] = React.useState<boolean>(false); const collapse = (): void => setExpand(false); @@ -56,14 +56,14 @@ const AppBar: React.FC = (_) => { {t("nav.about")} </NavLink> - {isAdministrator && ( + {hasAdministrationPermission && ( <NavLink to="/admin" className="nav-link" activeClassName="active" onClick={collapse} > - Administration + {t("nav.administration")} </NavLink> )} </Nav> diff --git a/FrontEnd/src/app/views/common/OperationDialog.tsx b/FrontEnd/src/app/views/common/OperationDialog.tsx index e32e9277..77ed851f 100644 --- a/FrontEnd/src/app/views/common/OperationDialog.tsx +++ b/FrontEnd/src/app/views/common/OperationDialog.tsx @@ -77,11 +77,6 @@ type MapOperationInputInfoValueTypeList< [Index in keyof Tuple]: MapOperationInputInfoValueType<Tuple[Index]>; } & { length: Tuple["length"] }; -interface OperationResult { - type: "success" | "failure"; - data: unknown; -} - export type OperationInputError = | { [index: number]: I18nText | null | undefined; @@ -98,36 +93,49 @@ const isNoError = (error: OperationInputError): boolean => { }; export interface OperationDialogProps< + TData, OperationInputInfoList extends readonly OperationInputInfo[] > { open: boolean; close: () => void; title: I18nText | (() => React.ReactNode); - titleColor?: "default" | "dangerous" | "create" | string; + themeColor?: "danger" | "success" | string; onProcess: ( inputs: MapOperationInputInfoValueTypeList<OperationInputInfoList> - ) => Promise<unknown>; + ) => Promise<TData>; inputScheme?: OperationInputInfoList; inputValidator?: ( inputs: MapOperationInputInfoValueTypeList<OperationInputInfoList> ) => OperationInputError; inputPrompt?: I18nText | (() => React.ReactNode); processPrompt?: () => React.ReactNode; - successPrompt?: (data: unknown) => React.ReactNode; + successPrompt?: (data: TData) => React.ReactNode; failurePrompt?: (error: unknown) => React.ReactNode; - onSuccessAndClose?: () => void; + onSuccessAndClose?: (data: TData) => void; } const OperationDialog = < + TData, OperationInputInfoList extends readonly OperationInputInfo[] >( - props: OperationDialogProps<OperationInputInfoList> + props: OperationDialogProps<TData, OperationInputInfoList> ): React.ReactElement => { - const inputScheme = props.inputScheme as readonly OperationInputInfo[]; + const inputScheme = (props.inputScheme ?? + []) as readonly OperationInputInfo[]; const { t } = useTranslation(); - type Step = "input" | "process" | OperationResult; + type Step = + | "input" + | "process" + | { + type: "success"; + data: TData; + } + | { + type: "failure"; + data: unknown; + }; const [step, setStep] = useState<Step>("input"); const [values, setValues] = useState<(boolean | string)[]>( inputScheme.map((i) => { @@ -153,7 +161,7 @@ const OperationDialog = < step.type === "success" && props.onSuccessAndClose ) { - props.onSuccessAndClose(); + props.onSuccessAndClose(step.data); } } else { console.log("Attempt to close modal when processing."); @@ -169,7 +177,7 @@ const OperationDialog = < > ) .then( - (d: unknown) => { + (d) => { setStep({ type: "success", data: d, @@ -305,7 +313,7 @@ const OperationDialog = < {t("operationDialog.cancel")} </Button> <LoadingButton - variant="primary" + variant={props.themeColor} loading={process} disabled={!canProcess} onClick={() => { @@ -354,14 +362,7 @@ const OperationDialog = < <Modal show={props.open} onHide={close}> <Modal.Header className={ - props.titleColor != null - ? "text-" + - (props.titleColor === "create" - ? "success" - : props.titleColor === "dangerous" - ? "danger" - : props.titleColor) - : undefined + props.themeColor != null ? "text-" + props.themeColor : undefined } > {title} diff --git a/FrontEnd/src/app/views/home/BoardWithUser.tsx b/FrontEnd/src/app/views/home/BoardWithUser.tsx index fbe1dd89..bbef835a 100644 --- a/FrontEnd/src/app/views/home/BoardWithUser.tsx +++ b/FrontEnd/src/app/views/home/BoardWithUser.tsx @@ -2,14 +2,14 @@ import React from "react"; import { Row, Col } from "react-bootstrap"; import { useTranslation } from "react-i18next"; -import { UserWithToken } from "@/services/user"; +import { AuthUser } from "@/services/user"; import { TimelineInfo } from "@/services/timeline"; import { getHttpTimelineClient } from "@/http/timeline"; import TimelineBoard from "./TimelineBoard"; import OfflineBoard from "./OfflineBoard"; -const BoardWithUser: React.FC<{ user: UserWithToken }> = ({ user }) => { +const BoardWithUser: React.FC<{ user: AuthUser }> = ({ user }) => { const { t } = useTranslation(); const [ownTimelines, setOwnTimelines] = React.useState< diff --git a/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx b/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx index 786ebb5d..12bbfb54 100644 --- a/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx +++ b/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx @@ -18,7 +18,7 @@ const TimelineCreateDialog: React.FC<TimelineCreateDialogProps> = (props) => { <OperationDialog open={props.open} close={props.close} - titleColor="success" + themeColor="success" title="home.createDialog.title" inputScheme={ [ diff --git a/FrontEnd/src/app/views/settings/index.tsx b/FrontEnd/src/app/views/settings/index.tsx index 1e0517d4..cbdae8ac 100644 --- a/FrontEnd/src/app/views/settings/index.tsx +++ b/FrontEnd/src/app/views/settings/index.tsx @@ -20,7 +20,7 @@ const ChangePasswordDialog: React.FC<ChangePasswordDialogProps> = (props) => { <OperationDialog open={props.open} title="settings.dialogChangePassword.title" - titleColor="dangerous" + themeColor="danger" inputPrompt="settings.dialogChangePassword.prompt" inputScheme={[ { diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx index ee49586e..aae227e6 100644 --- a/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx +++ b/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx @@ -33,7 +33,6 @@ const TimelinePropertyChangeDialog: React.FC<TimelinePropertyChangeDialogProps> return ( <OperationDialog title={"timeline.dialogChangeProperty.title"} - titleColor="default" inputScheme={[ { type: "text", diff --git a/FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx index 33609158..0d3199d6 100644 --- a/FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx +++ b/FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx @@ -22,7 +22,7 @@ const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => { open={props.open} close={props.close} title="timeline.deleteDialog.title" - titleColor="danger" + themeColor="danger" inputPrompt={() => { return ( <Trans i18nKey="timeline.deleteDialog.inputPrompt"> diff --git a/FrontEnd/src/app/views/user/ChangeNicknameDialog.tsx b/FrontEnd/src/app/views/user/ChangeNicknameDialog.tsx index 0e95b05b..f319ac37 100644 --- a/FrontEnd/src/app/views/user/ChangeNicknameDialog.tsx +++ b/FrontEnd/src/app/views/user/ChangeNicknameDialog.tsx @@ -13,7 +13,6 @@ const ChangeNicknameDialog: React.FC<ChangeNicknameDialogProps> = (props) => { <OperationDialog open={props.open} title="userPage.dialogChangeNickname.title" - titleColor="default" inputScheme={[ { type: "text", label: "userPage.dialogChangeNickname.inputLabel" }, ]} |