Skip to content

Commit

Permalink
Admin role (#345)
Browse files Browse the repository at this point in the history
* Add admin UserRole and add API to set it.

* Make basic user page

* Add Make Admin button

* fix build and lint

* DRY up requests.ts

* Don't watchEffect in a click handler

* react to successful setRole

* Address feedback on user ID API

* lint
  • Loading branch information
benjaminpjones authored Nov 28, 2024
1 parent dcfe2d5 commit c53b04a
Show file tree
Hide file tree
Showing 7 changed files with 139 additions and 14 deletions.
48 changes: 48 additions & 0 deletions packages/server/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import {
checkUsername,
createUserWithUsernameAndPassword,
deleteUser,
getUser,
getUserByName,
setUserRole,
} from "./users";
import {
getOnlyMove,
Expand Down Expand Up @@ -164,6 +166,52 @@ router.post("/register", async (req, res, next) => {
passport.authenticate("local", make_auth_cb(req, res))(req, res, next);
});

router.put("/users/:userId/role", async (req, res) => {
const { role } = req.body;
try {
if (!req.user || (req.user as UserResponse).role !== "admin") {
// unauthorized
res.status(401);
res.json("Only Admins may set user roles.");
return;
}

if (!["admin"].includes(role)) {
throw new Error(`Invalid role: ${role}`);
}

const userToUpdate = await getUser(req.params.userId);

if (userToUpdate.login_type !== "persistent") {
throw new Error(
`Cannot assign role "${role}" to user with "${userToUpdate.login_type}" type.`,
);
}

await setUserRole(req.params.userId, role);

userToUpdate.role = role;
res.send(userToUpdate);
} catch (e) {
res.status(500);
res.json(e.message);
}
});

router.get("/users/:userId", async (req, res) => {
try {
const user = await getUser(req.params.userId);
if (!user) {
res.status(404);
res.json("User does not exist");
}
res.send(user);
} catch (e) {
res.status(500);
res.json(e.message);
}
});

function make_auth_cb(
req: express.Request,
res: express.Response,
Expand Down
32 changes: 25 additions & 7 deletions packages/server/src/users.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { getDb } from "./db";
import { UserResponse, UserRankings } from "@ogfcommunity/variants-shared";
import {
UserResponse,
UserRankings,
UserRole,
} from "@ogfcommunity/variants-shared";
import { Collection, WithId, ObjectId } from "mongodb";
import { randomBytes, scrypt } from "node:crypto";

Expand Down Expand Up @@ -31,8 +35,10 @@ export async function updateUserRanking(
}
}

function usersCollection(): Collection<GuestUser | PersistentUser> {
return getDb().db().collection<GuestUser | PersistentUser>("users");
type DbUser = Omit<GuestUser, "id"> | Omit<PersistentUser, "id">;

function usersCollection(): Collection<DbUser> {
return getDb().db().collection<DbUser>("users");
}

export async function getUserByName(username: string): Promise<PersistentUser> {
Expand Down Expand Up @@ -131,7 +137,7 @@ export async function createUserWithUsernameAndPassword(
): Promise<UserResponse> {
const password_hash = await hashPassword(password);

const user: PersistentUser = {
const user: Omit<PersistentUser, "id"> = {
username,
password_hash,
login_type: "persistent",
Expand Down Expand Up @@ -183,14 +189,13 @@ export async function getUser(user_id: string): Promise<UserResponse> {
return outwardFacingUser(db_user);
}

function outwardFacingUser(
db_user: WithId<GuestUser | PersistentUser>,
): UserResponse {
function outwardFacingUser(db_user: WithId<DbUser>): UserResponse {
return {
id: db_user._id.toString(),
login_type: db_user.login_type,
...(db_user.login_type === "persistent" && { username: db_user.username }),
ranking: db_user.ranking || {},
role: db_user.role,
};
}

Expand All @@ -210,3 +215,16 @@ export function checkUsername(username: string): void {
throw "Username can only have alphanumeric characters.";
}
}

export async function setUserRole(
user_id: string,
role: UserRole,
): Promise<void> {
const update_result = await usersCollection().updateOne(
{ _id: new ObjectId(user_id) },
{ $set: { role: role } },
);
if (update_result.matchedCount == 0) {
throw new Error("User not found");
}
}
7 changes: 6 additions & 1 deletion packages/shared/src/api_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,16 @@ export interface GameResponse {
time_control?: ITimeControlBase;
}

// We may add more roles like "moderator" or "bot" in the future
export type UserRole = "admin";

export interface UserResponse {
id?: string;
id: string;
login_type: "guest" | "persistent";
username?: string;
ranking?: UserRankings;
// undefined is just a normal user.
role?: UserRole;
}

export type GamesFilter = {
Expand Down
9 changes: 6 additions & 3 deletions packages/vue-client/src/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ export async function get(path: string) {

// There's probably a way to get types into the APIs, but for now just have to
// silence this warning.
// eslint-disable-next-line
export async function post(path: string, json: any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function requestWithPayloadImpl(method: string, path: string, json: any) {
const headers = new Headers();
headers.append("Origin", SERVER_ORIGIN); // TODO: Is this necessary?
headers.append("Content-Type", "application/json");
const response = await fetch(SERVER_PATH_PREFIX + path, {
method: "post",
method,
body: JSON.stringify(json),
headers,
});
Expand All @@ -38,4 +38,7 @@ export async function post(path: string, json: any) {
return data;
}

export const post = requestWithPayloadImpl.bind(undefined, "post");
export const put = requestWithPayloadImpl.bind(undefined, "put");

export const socket = io(SERVER_ORIGIN);
6 changes: 6 additions & 0 deletions packages/vue-client/src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ const router = createRouter({
name: "rules-list",
component: () => import("../views/RulesListView.vue"),
},
{
path: "/users/:userId([0-9a-fA-F]+)",
name: "user",
component: () => import("../views/UserView.vue"),
props: true,
},
],
});

Expand Down
6 changes: 3 additions & 3 deletions packages/vue-client/src/stores/user.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { defineStore, storeToRefs } from "pinia";
import * as requests from "../requests";
import type { User } from "@ogfcommunity/variants-shared";
import type { UserResponse } from "@ogfcommunity/variants-shared";
import type { Ref } from "vue";

interface UserStoreStateTree {
user: User | null;
user: UserResponse | null;
}

export const useStore = defineStore("user", {
Expand Down Expand Up @@ -45,7 +45,7 @@ export const useStore = defineStore("user", {
},
});

export function useCurrentUser(): Ref<User | null> {
export function useCurrentUser(): Ref<UserResponse | null> {
const store = useStore();
return storeToRefs(store).user;
}
45 changes: 45 additions & 0 deletions packages/vue-client/src/views/UserView.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { UserResponse, UserRole } from "@ogfcommunity/variants-shared";
import { ref, watchEffect } from "vue";
import * as requests from "../requests";
import { useCurrentUser } from "@/stores/user";
const props = defineProps<{ userId: string }>();
const user = ref<UserResponse>();
const err = ref<string>();
watchEffect(async () => {
try {
user.value = await requests.get(`/users/${props.userId}`);
} catch (e) {
err.value = e as string;
}
});
const loggedInUser = useCurrentUser();
function setRole(role: UserRole) {
requests
.put(`/users/${props.userId}/role`, { role })
.then(() => (user.value = user.value ? { ...user.value, role } : undefined))
.catch(alert);
}
</script>

<template>
<main>
<div class="grid-page-layout">
<div v-if="user">
<div v-if="user.login_type === 'guest'">Guest User</div>
<div v-else>{{ user.username }}</div>
<div v-if="user.role">{{ user.role }}</div>
<div v-if="loggedInUser?.role === 'admin'">
<button v-if="user.role !== 'admin'" @click="setRole('admin')">
Make Admin
</button>
</div>
</div>
<div v-if="err">{{ err }}</div>
</div>
</main>
</template>

0 comments on commit c53b04a

Please sign in to comment.