diff --git a/src/apis/prisma/fetch-user.ts b/src/apis/prisma/fetch-user.ts
index f78483d..b713f5b 100644
--- a/src/apis/prisma/fetch-user.ts
+++ b/src/apis/prisma/fetch-user.ts
@@ -1,8 +1,8 @@
import "server-only";
import { NotAllowedError } from "@/error-classes";
-import { getUserId } from "@/features/auth/utils/get-session";
+import { getSelfRole, getUserId } from "@/features/auth/utils/get-session";
import prisma from "@/prisma";
-import type { Scope } from "@prisma/client";
+import type { Role, Scope } from "@prisma/client";
// everyone can access
export async function getUserScope(username: string) {
@@ -75,3 +75,30 @@ export async function getNewsAndContents(username: string) {
return newsAndContents;
}
+
+// ROLE === "admin" only
+export async function getUsers() {
+ const role = await getSelfRole();
+
+ if (role !== "ADMIN") throw new NotAllowedError();
+
+ return await prisma.users.findMany({
+ select: {
+ id: true,
+ username: true,
+ role: true,
+ Profile: true,
+ },
+ });
+}
+
+export async function updateRole(userId: string, role: Role) {
+ const selfRole = await getSelfRole();
+
+ if (selfRole !== "ADMIN") throw new NotAllowedError();
+
+ return await prisma.users.update({
+ where: { id: userId },
+ data: { role },
+ });
+}
diff --git a/src/app/admin/@users/page.tsx b/src/app/admin/@users/page.tsx
new file mode 100644
index 0000000..4654640
--- /dev/null
+++ b/src/app/admin/@users/page.tsx
@@ -0,0 +1,25 @@
+import { getUsers } from "@/apis/prisma/fetch-user";
+import { Unauthorized } from "@/components/unauthorized";
+import { UserCard } from "@/features/auth/components/user-card";
+import { checkAdminPermission } from "@/features/auth/utils/role";
+export const dynamic = "force-dynamic";
+
+export default async function Page() {
+ const hasAdminPermission = await checkAdminPermission();
+
+ const users = await getUsers();
+
+ return (
+ <>
+ {hasAdminPermission ? (
+
+ {users.map((user) => (
+
+ ))}
+
+ ) : (
+
+ )}
+ >
+ );
+}
diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx
index 76864a4..433e1df 100644
--- a/src/app/admin/layout.tsx
+++ b/src/app/admin/layout.tsx
@@ -13,9 +13,10 @@ export const metadata: Metadata = {
type Props = {
dumper: ReactNode;
+ users: ReactNode;
};
-export default async function Layout({ dumper }: Props) {
+export default async function Layout({ dumper, users }: Props) {
return (
<>
@@ -24,8 +25,12 @@ export default async function Layout({ dumper }: Props) {
DUMPER
+
+ USERS
+
{dumper}
+ {users}
>
);
diff --git a/src/constants.ts b/src/constants.ts
index d04e60c..6a78bd2 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -1,3 +1,5 @@
+import type { Role } from "@prisma/client";
+
export const PAGE_NAME = "private.s-hirano.com";
export const FORM_ERROR_MESSAGES = {
@@ -22,6 +24,7 @@ export const ERROR_MESSAGES = {
export const SUCCESS_MESSAGES = {
INSERTED: "正常に登録できました。",
SCOPE_UPDATED: "スコープを正常に変更しました。",
+ ROLE_UPDATED: "ロールを正常に更新しました。",
PROFILE_UPSERTED: "プロフィールを更新しました。",
SIGN_IN: "サインインに成功しました。",
SIGN_OUT: "サインアウトに成功しました。",
@@ -43,3 +46,5 @@ export const UTIL_URLS = [
{ name: "PORTAINER", url: "https://private.s-hirano.com:9443" },
{ name: "GRAFANA", url: "https://private.s-hirano.com:3001" },
] as const;
+
+export const ROLES: Role[] = ["ADMIN", "EDITOR", "VIEWER", "UNAUTHORIZED"];
diff --git a/src/features/auth/actions/change-role.ts b/src/features/auth/actions/change-role.ts
new file mode 100644
index 0000000..2358610
--- /dev/null
+++ b/src/features/auth/actions/change-role.ts
@@ -0,0 +1,32 @@
+"use server";
+import "server-only";
+import { sendLineNotifyMessage } from "@/apis/line-notify/fetch-message";
+import { updateRole } from "@/apis/prisma/fetch-user";
+import { SUCCESS_MESSAGES } from "@/constants";
+import { NotAllowedError } from "@/error-classes";
+import { wrapServerSideErrorForClient } from "@/error-wrapper";
+import type { ServerAction } from "@/types";
+import { formatUpdateRoleMessage } from "@/utils/format-for-line";
+import type { Role } from "@prisma/client";
+import { getSelfRole } from "../utils/get-session";
+
+export async function changeRole(
+ userId: string,
+ role: Role,
+): Promise> {
+ try {
+ const selfRole = await getSelfRole();
+ if (selfRole !== "ADMIN") throw new NotAllowedError();
+
+ await updateRole(userId, role);
+ await sendLineNotifyMessage(formatUpdateRoleMessage(role));
+
+ return {
+ success: true,
+ message: SUCCESS_MESSAGES.ROLE_UPDATED,
+ data: undefined,
+ };
+ } catch (error) {
+ return await wrapServerSideErrorForClient(error);
+ }
+}
diff --git a/src/features/auth/components/role-update-selector.tsx b/src/features/auth/components/role-update-selector.tsx
new file mode 100644
index 0000000..58f6fb5
--- /dev/null
+++ b/src/features/auth/components/role-update-selector.tsx
@@ -0,0 +1,55 @@
+"use client";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { ROLES } from "@/constants";
+import { useToast } from "@/hooks/use-toast";
+import type { Role } from "@prisma/client";
+import { useState } from "react";
+import { changeRole } from "../actions/change-role";
+
+type Props = {
+ userId: string;
+ role: Role;
+};
+
+export function RoleUpdateSelector({ userId, role }: Props) {
+ const { toast } = useToast();
+
+ const [value, setValue] = useState(role);
+
+ async function handleScopeChange(value: Role) {
+ const response = await changeRole(userId, value);
+ if (!response.success) {
+ toast({
+ variant: "destructive",
+ description: response.message,
+ });
+ return;
+ }
+ toast({
+ variant: "default",
+ description: response.message,
+ });
+ setValue(value);
+ }
+
+ return (
+
+ );
+}
diff --git a/src/features/auth/components/user-card.tsx b/src/features/auth/components/user-card.tsx
new file mode 100644
index 0000000..a2fc040
--- /dev/null
+++ b/src/features/auth/components/user-card.tsx
@@ -0,0 +1,23 @@
+"use client";
+import type { getUsers } from "@/apis/prisma/fetch-user";
+import type { UnwrapPromise } from "@/types";
+import { RoleUpdateSelector } from "./role-update-selector";
+
+type Props = {
+ user: UnwrapPromise>[0];
+};
+
+export function UserCard({ user }: Props) {
+ return (
+
+
+
ユーザー情報
+
{user.id}
+
{user.username}
+
+
+
+
+
+ );
+}
diff --git a/src/types.ts b/src/types.ts
index 70cd29b..af4ec7c 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -9,3 +9,5 @@ type Action = {
export type ServerAction =
| (Action & { success: true; data: T })
| (Action & { success: false });
+
+export type UnwrapPromise = T extends Promise ? U : T;
diff --git a/src/utils/format-for-line.ts b/src/utils/format-for-line.ts
index 5110c97..b30a626 100644
--- a/src/utils/format-for-line.ts
+++ b/src/utils/format-for-line.ts
@@ -3,8 +3,8 @@ import type { createSelfNews } from "@/apis/prisma/fetch-news";
import type { ContentName } from "@/features/dump/types";
import type { ProfileSchema } from "@/features/profile/schemas/profile-schema";
import type { Status } from "@/features/update-status/types";
-import type { Scope } from "@prisma/client";
-type UnwrapPromise = T extends Promise ? U : T;
+import type { UnwrapPromise } from "@/types";
+import type { Role, Scope } from "@prisma/client";
export function formatChangeStatusMessage(
status: Status,
@@ -44,3 +44,7 @@ export function formatUpdateScopeMessage(scope: Scope) {
export function formatUpsertProfileMessage(data: ProfileSchema) {
return `【PROFILE】\n\nname: ${data.name}\nに変更しました`;
}
+
+export function formatUpdateRoleMessage(role: Role) {
+ return `【ROLE】\n\nrole: ${role}\nに変更しました`;
+}