diff --git a/src/common/utils/admin.ts b/src/common/utils/admin.ts new file mode 100644 index 00000000..e81fe9e5 --- /dev/null +++ b/src/common/utils/admin.ts @@ -0,0 +1,32 @@ +import type { ComputedUserRole } from './roles' +import { type UserFull, UserRole, type UserSecrets } from '~/def/user' + +// TODO server validation impl same logic +export function isRoleEditable(stat: Record<'admin' | 'owner' | 'staff', boolean>, role: UserRole) { + switch (role) { + case UserRole.Admin: { + return stat.owner + } + case UserRole.Staff: { + return stat.admin || stat.owner + } + case UserRole.Owner: { + return stat.owner + } + case UserRole.Moderator: + return stat.admin || stat.owner + default: + return true + } +} + +export function isUserFieldEditable(field: keyof (UserFull & UserSecrets), computedRole: ComputedUserRole) { + switch (field) { + case 'profile': + case 'preferredMode': + case 'roles': + return computedRole.staff || computedRole.admin || computedRole.owner + default: + return computedRole.admin + } +} diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 627fc379..4ba05836 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -8,6 +8,7 @@ export * as localeKey from './locales' export * from './locale-path' export * from './map' export * from './roles' +export * from './admin' export function noop(): T export function noop(): void {} diff --git a/src/common/utils/roles.ts b/src/common/utils/roles.ts index eea0c545..26b58192 100644 --- a/src/common/utils/roles.ts +++ b/src/common/utils/roles.ts @@ -1,9 +1,11 @@ import { UserRole } from '~/def/user' -export function computeUserRoles(user: { roles: UserRole[] }) { - const admin = user.roles.includes(UserRole.Admin) - const owner = user.roles.includes(UserRole.Owner) +export type ComputedUserRole = Record<'admin' | 'owner' | 'staff', boolean> + +export function computeUserRoles(user: { roles: UserRole[] }): ComputedUserRole { + const admin = isAdmin(user) const staff = isStaff(user) + const owner = user.roles.includes(UserRole.Owner) return { admin, @@ -19,3 +21,10 @@ export function isStaff(user: { roles: UserRole[] }) { || role === UserRole.Owner ) } + +export function isAdmin(user: { roles: UserRole[] }) { + return user.roles.some(role => + role === UserRole.Admin + || role === UserRole.Owner + ) +} diff --git a/src/def/user.ts b/src/def/user.ts index de3a7901..8afb364d 100755 --- a/src/def/user.ts +++ b/src/def/user.ts @@ -51,8 +51,8 @@ export enum UserRole { // users that have privileges TournamentStaff = 'tournamentStaff', ChannelModerator = 'channelModerator', - Moderator = 'moderator', BeatmapNominator = 'beatmapNominator', + Moderator = 'moderator', Staff = 'staff', Admin = 'admin', diff --git a/src/middleware/admin.ts b/src/middleware/admin.ts index a7470f57..5ca8ef20 100644 --- a/src/middleware/admin.ts +++ b/src/middleware/admin.ts @@ -2,7 +2,7 @@ import { useSession } from '~/store/session' export default defineNuxtRouteMiddleware(() => { const { $state } = useSession() - if (!$state.role.staff) { + if (!$state.role.admin) { return navigateTo({ name: 'article-id', params: { diff --git a/src/middleware/staff.ts b/src/middleware/staff.ts new file mode 100644 index 00000000..a7470f57 --- /dev/null +++ b/src/middleware/staff.ts @@ -0,0 +1,13 @@ +import { useSession } from '~/store/session' + +export default defineNuxtRouteMiddleware(() => { + const { $state } = useSession() + if (!$state.role.staff) { + return navigateTo({ + name: 'article-id', + params: { + id: ['403'], + }, + }) + } +}) diff --git a/src/pages/admin.vue b/src/pages/admin.vue index be45c804..01e1dea8 100644 --- a/src/pages/admin.vue +++ b/src/pages/admin.vue @@ -1,6 +1,6 @@ diff --git a/src/pages/admin/index.vue b/src/pages/admin/index.vue index 0d7ec281..1ba984f7 100644 --- a/src/pages/admin/index.vue +++ b/src/pages/admin/index.vue @@ -1,5 +1,8 @@ @@ -146,9 +131,16 @@ de-DE: @@ -269,4 +269,3 @@ de-DE: } } -$base/server/@traits diff --git a/src/server/backend/$base/server/admin.ts b/src/server/backend/$base/server/admin.ts index 7895d0d0..59036e45 100644 --- a/src/server/backend/$base/server/admin.ts +++ b/src/server/backend/$base/server/admin.ts @@ -1,16 +1,19 @@ import { IdTransformable } from './@extends' import type { Composition } from './@common' +import { type ComputedUserRole } from '~/utils/common' import type { UserClan, UserCompact, UserOptional, UserSecrets } from '~/def/user' export abstract class AdminProvider extends IdTransformable { - abstract userList(query: Partial & Pick> & Partial & Composition.Pagination): - Promise< + abstract userList( + query: Partial & Pick> & + Partial & + Composition.Pagination + ): Promise< readonly [ number, Array< - UserCompact - & Pick - & { + UserCompact & + Pick & { registeredAt: Date lastActivityAt: Date clan?: UserClan @@ -18,6 +21,12 @@ export abstract class AdminProvider extends IdTransformable { >, ] > - abstract userDetail(query: { id: Id }): Promise & UserOptional> - abstract updateUserDetail(query: { id: Id }, updateFields: Partial & UserOptional>): Promise & UserOptional> + abstract userDetail(query: { + id: Id + }): Promise & UserOptional> + abstract updateUserDetail( + updater: { role: ComputedUserRole }, + query: { id: Id }, + updateFields: Partial & UserOptional> + ): Promise & UserOptional> } diff --git a/src/server/backend/bancho.py/server/admin.ts b/src/server/backend/bancho.py/server/admin.ts index a7ab46ff..9b711b9f 100644 --- a/src/server/backend/bancho.py/server/admin.ts +++ b/src/server/backend/bancho.py/server/admin.ts @@ -4,12 +4,13 @@ import { encryptBanchoPassword } from '../crypto' import * as schema from '../drizzle/schema' import { config } from '../env' import { Logger } from '../log' -import { type DatabaseUserCompactFields, type DatabaseUserOptionalFields, fromCountryCode, toBanchoPyPriv, toSafeName, toUserCompact, toUserOptional } from '../transforms' +import { type DatabaseUserCompactFields, type DatabaseUserOptionalFields, fromCountryCode, toBanchoPyPriv, toRoles, toSafeName, toUserCompact, toUserOptional } from '../transforms' import { BanchoPyPrivilege } from '../enums' import { useDrizzle } from './source/drizzle' import { GucchoError } from '~/def/messages' import { type UserClan, type UserCompact, type UserOptional, UserRole, type UserSecrets } from '~/def/user' import { AdminProvider as Base } from '$base/server' +import { type ComputedUserRole } from '~/utils/common' const logger = Logger.child({ label: 'user' }) @@ -20,10 +21,13 @@ export class AdminProvider extends Base implements Base { config = config() drizzle = drizzle logger = logger - async userList(query: Partial & Pick> & Partial & { - page: number - perPage: number - }) { + async userList( + query: Partial & Pick> & + Partial & { + page: number + perPage: number + } + ) { const rolesPriv = toBanchoPyPriv(query.roles || [], 0) const cond = [ @@ -36,51 +40,72 @@ export class AdminProvider extends Base implements Base { query.roles?.includes(UserRole.Restricted) ? sql`${schema.users.priv} & 1 = 0` : undefined, ] - const baseQuery = this.drizzle.select({ - user: pick(schema.users, ['id', 'name', 'safeName', 'priv', 'country', 'email', 'preferredMode', 'lastActivity', 'creationTime'] satisfies Array), - clan: pick(schema.clans, ['id', 'name', 'badge']), - }).from(schema.users) + const baseQuery = this.drizzle + .select({ + user: pick(schema.users, [ + 'id', + 'name', + 'safeName', + 'priv', + 'country', + 'email', + 'preferredMode', + 'lastActivity', + 'creationTime', + ] satisfies Array< + | DatabaseUserCompactFields + | DatabaseUserOptionalFields + | DatabaseAdminUserFields + >), + clan: pick(schema.clans, ['id', 'name', 'badge']), + }) + .from(schema.users) .leftJoin(schema.clans, eq(schema.clans.id, schema.users.clanId)) - .where( - and( - ...cond - ) - ) + .where(and(...cond)) .orderBy(desc(schema.users.lastActivity)) .offset(query.page * query.perPage) .limit(query.perPage) - const uCompacts = baseQuery.then(res => res.map(({ user, clan }) => ({ - ...toUserCompact(user, this.config), - ...toUserOptional(user), - lastActivityAt: new Date(user.lastActivity * 1000), - registeredAt: new Date(user.creationTime * 1000), - clan: clan - ? { - id: clan.id, - name: clan.name, - badge: clan.badge, - } satisfies UserClan - : undefined, - }))) satisfies Promise & Pick & { - registeredAt: Date - lastActivityAt: Date - clan?: UserClan - }>> + const uCompacts = baseQuery.then(res => + res.map(({ user, clan }) => ({ + ...toUserCompact(user, this.config), + ...toUserOptional(user), + lastActivityAt: new Date(user.lastActivity * 1000), + registeredAt: new Date(user.creationTime * 1000), + clan: clan + ? ({ + id: clan.id, + name: clan.name, + badge: clan.badge, + } satisfies UserClan) + : undefined, + })) + ) satisfies Promise< + Array< + UserCompact & + Pick & { + registeredAt: Date + lastActivityAt: Date + clan?: UserClan + } + > + > return Promise.all([ - this.drizzle.select({ - count: sql`count(*)`.mapWith(Number), - }).from(schema.users) + this.drizzle + .select({ + count: sql`count(*)`.mapWith(Number), + }) + .from(schema.users) .leftJoin(schema.clans, eq(schema.clans.id, schema.users.clanId)) .where( and(...cond) - ).execute() + ) + .execute() .then(res => res[0].count), uCompacts, - ] as const) } @@ -95,33 +120,77 @@ export class AdminProvider extends Base implements Base { } } - async updateUserDetail(query: { id: Id }, updateFields: Partial & UserOptional & UserSecrets>): Promise & UserOptional> { - const { priv } = await this.drizzle.query.users.findFirst({ - where: eq(schema.users.id, query.id), - columns: { - priv: true, - }, - }) ?? throwGucchoError(GucchoError.UserNotFound) + /** + * This function merges the current roles with the roles to be updated. + * It filters out the roles that cannot be edited by the current user, + * and then appends the roles that can be edited. + * + */ + mergeUpdateRoles( + updater: { role: ComputedUserRole }, + currentRoles: UserRole[], + updateRoles: UserRole[] + ) { + return currentRoles + // Filter out the roles that cannot be edited by the current user. + // The current user can only edit roles that are not editable by themselves. + .filter(r => + !isRoleEditable(updater.role, r) + ) + // Filter out the roles that can be edited by the current user. + // The current user can edit roles that are editable by themselves. + .concat( + updateRoles.filter(r => + isRoleEditable(updater.role, r) + ) + ) + } + + async updateUserDetail( + updater: { role: ComputedUserRole }, + query: { id: Id }, + updateFields: Partial & UserOptional & UserSecrets> + ): Promise & UserOptional> { + const { priv } + = (await this.drizzle.query.users.findFirst({ + where: eq(schema.users.id, query.id), + columns: { + priv: true, + }, + })) ?? throwGucchoError(GucchoError.UserNotFound) + + const currentRoles = toRoles(priv) const basePriv = updateFields.roles?.includes(UserRole.Restricted) ? BanchoPyPrivilege.Any | (priv & BanchoPyPrivilege.Verified) : BanchoPyPrivilege.Registered | (priv & BanchoPyPrivilege.Verified) - await this.drizzle.update(schema.users) + await this.drizzle + .update(schema.users) .set({ id: updateFields.id, name: updateFields.name, safeName: updateFields.name ? toSafeName(updateFields.name) : undefined, - pwBcrypt: updateFields.password ? await encryptBanchoPassword(updateFields.password) : undefined, + pwBcrypt: updateFields.password + ? await encryptBanchoPassword(updateFields.password) + : undefined, email: updateFields.email, - country: updateFields.flag ? fromCountryCode(updateFields.flag) : undefined, - priv: updateFields.roles ? toBanchoPyPriv(updateFields.roles, basePriv) : undefined, + country: updateFields.flag + ? fromCountryCode(updateFields.flag) + : undefined, + priv: updateFields.roles + ? toBanchoPyPriv( + this.mergeUpdateRoles(updater, currentRoles, updateFields.roles), + basePriv + ) + : undefined, }) .where(eq(schema.users.id, query.id)) - const user = await this.drizzle.query.users.findFirst({ - where: eq(schema.users.id, updateFields.id ?? query.id), - }) ?? raise(Error, 'cannot find updated user. Did you changed user id?') + const user + = (await this.drizzle.query.users.findFirst({ + where: eq(schema.users.id, updateFields.id ?? query.id), + })) ?? raise(Error, 'cannot find updated user. Did you changed user id?') return { ...toUserCompact(user, this.config), diff --git a/src/server/trpc/middleware/admin.ts b/src/server/trpc/middleware/role.ts similarity index 58% rename from src/server/trpc/middleware/admin.ts rename to src/server/trpc/middleware/role.ts index b20607d2..75398322 100644 --- a/src/server/trpc/middleware/admin.ts +++ b/src/server/trpc/middleware/role.ts @@ -15,9 +15,23 @@ export const roleProcedure = userProcedure.use(async ({ ctx, next }) => { }) }) -export const adminProcedure = roleProcedure.use(({ ctx, next }) => { +export const staffProcedure = roleProcedure.use(({ ctx, next }) => { if (!ctx.user.role.staff) { throwGucchoError(GucchoError.RequireAdminPrivilege) } return next() }) + +export const adminProcedure = roleProcedure.use(({ ctx, next }) => { + if (!ctx.user.role.admin) { + throwGucchoError(GucchoError.RequireAdminPrivilege) + } + return next() +}) + +export const ownerProcedure = roleProcedure.use(({ ctx, next }) => { + if (!ctx.user.role.owner) { + throwGucchoError(GucchoError.RequireAdminPrivilege) + } + return next() +}) diff --git a/src/server/trpc/routers/admin/index.ts b/src/server/trpc/routers/admin/index.ts index 0db22f87..5b4d2583 100644 --- a/src/server/trpc/routers/admin/index.ts +++ b/src/server/trpc/routers/admin/index.ts @@ -2,44 +2,47 @@ import { array, nativeEnum, number, object, string, tuple } from 'zod' import { zodHandle } from '../../shapes' import { router as log } from './log' import { UserProvider, admin } from '~/server/singleton/service' -import { adminProcedure as pAdmin } from '~/server/trpc/middleware/admin' +import { staffProcedure } from '~/server/trpc/middleware/role' import { router as _router } from '~/server/trpc/trpc' import { UserRole } from '~/def/user' import { CountryCode } from '~/def/country-code' +const searchParam = object({ + id: string().trim(), + name: string().trim(), + safeName: string().trim(), + email: string().email(), + flag: nativeEnum(CountryCode), + registeredFrom: string().datetime(), + registeredTo: string().datetime(), + latestActivityFrom: string().datetime(), + latestActivityTo: string().datetime(), + roles: array(nativeEnum(UserRole)), +}) + .partial() + .and( + object({ + perPage: number().default(10), + page: number().default(0), + }) + ) + export const router = _router({ log, - serverStatus: pAdmin.query(() => { - return { - t: 1, - } - }), userManagement: _router({ - search: pAdmin.input(object({ - id: string().trim(), - name: string().trim(), - safeName: string().trim(), - email: string().email(), - flag: nativeEnum(CountryCode), - registeredFrom: string().datetime(), - registeredTo: string().datetime(), - latestActivityFrom: string().datetime(), - latestActivityTo: string().datetime(), - roles: array(nativeEnum(UserRole)), - }).partial().and(object({ - perPage: number().default(10), - page: number().default(0), - }))).query(({ input }) => { - return admin.userList({ - ...input, - flag: input.flag === CountryCode.Unknown ? undefined : input.flag, - id: input.id ? UserProvider.stringToId(input.id) : undefined, - }) - }), - detail: pAdmin.input(string()).query(({ input }) => { + search: staffProcedure + .input(searchParam) + .query(({ input }) => { + return admin.userList({ + ...input, + flag: input.flag === CountryCode.Unknown ? undefined : input.flag, + id: input.id ? UserProvider.stringToId(input.id) : undefined, + }) + }), + detail: staffProcedure.input(string()).query(({ input }) => { return admin.userDetail({ id: UserProvider.stringToId(input) }).then(detail => mapId(detail, UserProvider.idToString)) }), - saveDetail: pAdmin + saveDetail: staffProcedure .input( tuple([ string(), @@ -55,16 +58,23 @@ export const router = _router({ }).partial(), ]) ) - .mutation(async ({ input }) => { - const [id, newVal] = input + .mutation(async ({ input, ctx }) => { + let [id, newVal] = input + // staff can only change some fields + if (!ctx.user.role.admin) { + const keys = Object.keys(newVal).filter(i => isUserFieldEditable(i as keyof typeof newVal, ctx.user.role)) as Array + newVal = pick(newVal, keys) + } const res = await admin.updateUserDetail( + ctx.user, { id: UserProvider.stringToId(id), }, { ...newVal, id: newVal.id ? UserProvider.stringToId(newVal.id) : undefined, - }) + }, + ) return mapId(res, UserProvider.idToString) }), diff --git a/src/server/trpc/routers/admin/log.ts b/src/server/trpc/routers/admin/log.ts index 382176fd..4e477d3a 100644 --- a/src/server/trpc/routers/admin/log.ts +++ b/src/server/trpc/routers/admin/log.ts @@ -1,6 +1,6 @@ import { number } from 'zod' import { logs } from '~/server/singleton/service' -import { adminProcedure as pAdmin } from '~/server/trpc/middleware/admin' +import { staffProcedure as pAdmin } from '~/server/trpc/middleware/role' import { router as _router } from '~/server/trpc/trpc' export const router = _router({ diff --git a/src/server/trpc/routers/article.ts b/src/server/trpc/routers/article.ts index 7a1d0235..24c85c76 100644 --- a/src/server/trpc/routers/article.ts +++ b/src/server/trpc/routers/article.ts @@ -1,6 +1,6 @@ import { any, array, boolean, object, record, string, union } from 'zod' import { router as _router } from '../trpc' -import { adminProcedure } from '../middleware/admin' +import { staffProcedure } from '../middleware/role' import { optionalUserProcedure } from '../middleware/optional-user' import { userProcedure } from '../middleware/user' import { type ArticleProvider as BaseArticleProvider } from '$base/server/article' @@ -73,7 +73,7 @@ export const router = _router({ } }), - save: adminProcedure + save: staffProcedure .input(object({ slug: string().trim(), json: record(any(), any()).refine((arg): arg is BaseArticleProvider.JSONContent => { @@ -87,13 +87,13 @@ export const router = _router({ })) .mutation(({ input, ctx }) => articles.save(Object.assign(input, { user: ctx.user }))), - delete: adminProcedure + delete: staffProcedure .input(object({ slug: string().trim(), })) .mutation(({ input, ctx }) => articles.delete(Object.assign(input, { user: ctx.user }))), - localSlugs: adminProcedure + localSlugs: staffProcedure .input(string().trim().optional()) .query(({ input }) => ArticleProvider.getLocalSlugs(input)), }) diff --git a/src/server/trpc/routers/status.ts b/src/server/trpc/routers/status.ts index 3e4778c3..6186e7e5 100644 --- a/src/server/trpc/routers/status.ts +++ b/src/server/trpc/routers/status.ts @@ -1,9 +1,9 @@ import { router as _router, publicProcedure as p } from '../trpc' -import { adminProcedure } from '../middleware/admin' +import { ownerProcedure, staffProcedure } from '../middleware/role' import { MonitorProvider, monitor } from '~/server/singleton/service' export const router = _router({ public: p.query(monitor.reportStatus), - metrics: adminProcedure.query(MonitorProvider.metrics), - config: adminProcedure.query(MonitorProvider.config), + metrics: staffProcedure.query(MonitorProvider.metrics), + config: ownerProcedure.query(MonitorProvider.config), })