aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorcrupest <crupest@outlook.com>2020-11-22 09:53:31 +0800
committerGitHub <noreply@github.com>2020-11-22 09:53:31 +0800
commitb3c9cf4bf66bd3b78e94dc522c53e7f7522897f0 (patch)
tree996eed1f087a50e8d5cd865e1440f4dbb8a7873b
parente0785b385138057a23ffd1703a7265c371aef45d (diff)
parentf1aabc06f1005b26bd1c0c5f36c98c28a62fc31e (diff)
downloadtimeline-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.
-rw-r--r--BackEnd/Timeline.Tests/IntegratedTests/UserPermissionTest.cs14
-rw-r--r--BackEnd/Timeline.Tests/IntegratedTests/UserTest.cs6
-rw-r--r--BackEnd/Timeline.Tests/Services/UserPermissionServiceTest.cs18
-rw-r--r--BackEnd/Timeline/Models/Http/UserInfo.cs4
-rw-r--r--BackEnd/Timeline/Models/User.cs2
-rw-r--r--BackEnd/Timeline/Services/UserPermissionService.cs2
-rw-r--r--BackEnd/Timeline/Services/UserService.cs1
-rw-r--r--FrontEnd/src/app/App.tsx2
-rw-r--r--FrontEnd/src/app/http/user.ts83
-rw-r--r--FrontEnd/src/app/index.sass11
-rw-r--r--FrontEnd/src/app/locales/en/admin.json36
-rw-r--r--FrontEnd/src/app/locales/en/translation.json3
-rw-r--r--FrontEnd/src/app/locales/zh/admin.json36
-rw-r--r--FrontEnd/src/app/locales/zh/translation.json3
-rw-r--r--FrontEnd/src/app/services/timeline.ts22
-rw-r--r--FrontEnd/src/app/services/user.ts57
-rw-r--r--FrontEnd/src/app/views/admin/Admin.tsx83
-rw-r--r--FrontEnd/src/app/views/admin/AdminNav.tsx44
-rw-r--r--FrontEnd/src/app/views/admin/HighlightTimelineAdmin.tsx13
-rw-r--r--FrontEnd/src/app/views/admin/UserAdmin.tsx611
-rw-r--r--FrontEnd/src/app/views/admin/admin.sass22
-rw-r--r--FrontEnd/src/app/views/common/AppBar.tsx6
-rw-r--r--FrontEnd/src/app/views/common/OperationDialog.tsx47
-rw-r--r--FrontEnd/src/app/views/home/BoardWithUser.tsx4
-rw-r--r--FrontEnd/src/app/views/home/TimelineCreateDialog.tsx2
-rw-r--r--FrontEnd/src/app/views/settings/index.tsx2
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx1
-rw-r--r--FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx2
-rw-r--r--FrontEnd/src/app/views/user/ChangeNicknameDialog.tsx1
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" },
]}