{{ t('cashback.confirm_issue') }}
diff --git a/apps/admin/src/views/Contents.vue b/apps/admin/src/views/Contents.vue
index 4a234a9..5c6358b 100644
--- a/apps/admin/src/views/Contents.vue
+++ b/apps/admin/src/views/Contents.vue
@@ -3,6 +3,8 @@ import { ref, computed, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import type { TableInstance } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
+import { usePermissions } from '../composables/usePermissions';
+import { AdminPerm } from '../constants/permissions';
import api from '../api';
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
import {
@@ -11,6 +13,8 @@ import {
} from './match-form';
const { t, localeTag } = useAdminLocale();
+const { hasPermission } = usePermissions();
+const canManageContent = computed(() => hasPermission(AdminPerm.content));
/* ── Image upload helpers ── */
const IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif,image/svg+xml';
@@ -470,9 +474,10 @@ void load();
{{ t('common.search') }}
-
+
{{ t('content.btn.create') }}
+
{{ t('content.batch.selected', { n: selectedRows.length }) }}
@@ -499,6 +504,7 @@ void load();
>
{{ t('content.batch.delete') }}
+
diff --git a/apps/admin/src/views/HomeEntry.vue b/apps/admin/src/views/HomeEntry.vue
index ca02beb..10c5d08 100644
--- a/apps/admin/src/views/HomeEntry.vue
+++ b/apps/admin/src/views/HomeEntry.vue
@@ -1,17 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ roleLabel(row.role) }}
+
+
+ {{ row.status }}
+
+
+ {{ formatTime(row.lastLoginAt) }}
+
+
+
+ {{ t('common.edit') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('common.cancel') }}
+ {{ t('common.confirm') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('common.cancel') }}
+ {{ t('common.confirm') }}
+
+
+
+
+
+
diff --git a/apps/admin/src/views/dashboard/DashboardMatches.vue b/apps/admin/src/views/dashboard/DashboardMatches.vue
index 5b641c4..bce2a8b 100644
--- a/apps/admin/src/views/dashboard/DashboardMatches.vue
+++ b/apps/admin/src/views/dashboard/DashboardMatches.vue
@@ -3,11 +3,14 @@ import { computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useAdminDashboard } from '../../composables/useAdminDashboard';
import EChartPanel from '../../components/dashboard/EChartPanel.vue';
-import { buildCombinedTrendOption, buildTriplePieOption } from '../../utils/dashboard-charts';
+import { buildCombinedTrendOption, buildTriplePieOption, buildBarChartOption } from '../../utils/dashboard-charts';
import { betStatusLabel } from '../../utils/bet-labels';
import { useAdminLocale } from '../../composables/useAdminLocale';
+import { usePermissions } from '../../composables/usePermissions';
const { t } = useAdminLocale();
+const { role } = usePermissions();
+const isMatchOperatorView = computed(() => role.value === 'MATCH_ADMIN');
const router = useRouter();
const {
s,
@@ -32,8 +35,16 @@ function goKpiLink(link: KpiLink) {
router.push(link.query ? { path: link.path, query: link.query } : link.path);
}
-const mainTrendOption = computed(() =>
- buildCombinedTrendOption(
+const mainTrendOption = computed(() => {
+ const counts = s.value?.trend7d?.map((d) => d.betCount) ?? [];
+ if (isMatchOperatorView.value) {
+ return buildBarChartOption(
+ trendLabels.value,
+ [{ name: t('dash.chart_bet_count'), color: '#956400', values: counts }],
+ { amountAxis: false },
+ );
+ }
+ return buildCombinedTrendOption(
trendLabels.value,
[
{
@@ -52,10 +63,10 @@ const mainTrendOption = computed(() =>
values: s.value?.trend7d?.map((d) => toNum(d.ggr)) ?? [],
},
],
- s.value?.trend7d?.map((d) => d.betCount) ?? [],
+ counts,
chartI18n.value,
- ),
-);
+ );
+});
const distributionOption = computed(() => {
const m = s.value?.matches;
@@ -146,13 +157,15 @@ const kpiMatch = computed(() => {
- {{ t('dash.section_matches_hint') }}
+ {{
+ isMatchOperatorView ? t('dash.section_match_ops_hint') : t('dash.section_matches_hint')
+ }}
{{ t('common.updated_at') }} {{ formatTime(s.generatedAt) }}
-
+
{{ item.label }}
{{ item.value }}
@@ -166,7 +179,7 @@ const kpiMatch = computed(() => {
-
+
{
-
{{ t('dash.trend_caption') }}
+
{{
+ isMatchOperatorView ? t('dash.trend_bet_count_caption') : t('dash.trend_caption')
+ }}
diff --git a/apps/api/src/applications/admin/admin-permissions.ts b/apps/api/src/applications/admin/admin-permissions.ts
index 53f84c0..ec220ff 100644
--- a/apps/api/src/applications/admin/admin-permissions.ts
+++ b/apps/api/src/applications/admin/admin-permissions.ts
@@ -3,6 +3,7 @@ export const P = {
reports: 'reports.view',
usersView: 'users.view',
usersCreate: 'users.create',
+ usersResetPassword: 'users.reset_password',
settings: 'settings.manage',
agentsView: 'agents.view',
agentsCreate: 'agents.create',
diff --git a/apps/api/src/applications/admin/admin-rbac.spec.ts b/apps/api/src/applications/admin/admin-rbac.spec.ts
new file mode 100644
index 0000000..c403daf
--- /dev/null
+++ b/apps/api/src/applications/admin/admin-rbac.spec.ts
@@ -0,0 +1,100 @@
+import { ExecutionContext } from '@nestjs/common';
+import { Reflector } from '@nestjs/core';
+import { PermissionsGuard } from '../../domains/identity/guards';
+import { isAuditListUnrestricted } from '../../domains/operations/audit/audit-list-scope';
+import { PERMISSIONS_KEY } from '../../shared/common/decorators';
+
+/** Mirrors run-seed.ts role permission assignments (SEC010–SEC012). */
+const ROLE_PERMISSIONS: Record
= {
+ SUPER_ADMIN: ['*'],
+ MATCH_ADMIN: [
+ 'matches.manage',
+ 'settlement.confirm',
+ 'content.manage',
+ 'bets.view',
+ 'reports.view',
+ 'audit.view',
+ ],
+ FINANCE_ADMIN: [
+ 'wallet.deposit',
+ 'wallet.withdraw',
+ 'cashback.confirm',
+ 'agents.view',
+ 'agents.credit',
+ 'users.view',
+ 'users.create',
+ 'deposit.manage',
+ 'deposit.review',
+ 'reports.view',
+ 'bets.view',
+ 'audit.view',
+ ],
+ SUPPORT: ['users.view', 'users.reset_password', 'bets.view', 'reports.view', 'audit.view'],
+};
+
+function mockContext(user: Record): ExecutionContext {
+ return {
+ switchToHttp: () => ({
+ getRequest: () => ({ user }),
+ }),
+ getHandler: () => ({}),
+ getClass: () => ({}),
+ } as ExecutionContext;
+}
+
+function guardAllows(user: Record, ...required: string[]): boolean {
+ const reflector = {
+ getAllAndOverride: (key: string) => (key === PERMISSIONS_KEY ? required : undefined),
+ } as unknown as Reflector;
+ const guard = new PermissionsGuard(reflector);
+ try {
+ return guard.canActivate(mockContext(user));
+ } catch {
+ return false;
+ }
+}
+
+function userWithRole(role: string) {
+ return {
+ userType: 'ADMIN',
+ role,
+ permissions: ROLE_PERMISSIONS[role] ?? [],
+ };
+}
+
+describe('Admin RBAC (SEC010–SEC012)', () => {
+ it('SEC010: SUPPORT cannot perform wallet deposit', () => {
+ const user = userWithRole('SUPPORT');
+ expect(guardAllows(user, 'wallet.deposit')).toBe(false);
+ expect(guardAllows(user, 'users.view')).toBe(true);
+ expect(guardAllows(user, 'users.reset_password')).toBe(true);
+ });
+
+ it('SEC011: FINANCE_ADMIN cannot manage matches', () => {
+ const user = userWithRole('FINANCE_ADMIN');
+ expect(guardAllows(user, 'matches.manage')).toBe(false);
+ expect(guardAllows(user, 'wallet.deposit')).toBe(true);
+ expect(guardAllows(user, 'agents.credit')).toBe(true);
+ });
+
+ it('SEC012: MATCH_ADMIN cannot perform wallet deposit', () => {
+ const user = userWithRole('MATCH_ADMIN');
+ expect(guardAllows(user, 'wallet.deposit')).toBe(false);
+ expect(guardAllows(user, 'settlement.confirm')).toBe(true);
+ expect(guardAllows(user, 'content.manage')).toBe(true);
+ expect(guardAllows(user, 'settlement.resettle')).toBe(false);
+ });
+
+ it('SUPER_ADMIN bypasses permission checks', () => {
+ const user = userWithRole('SUPER_ADMIN');
+ expect(guardAllows(user, 'wallet.deposit')).toBe(true);
+ expect(guardAllows(user, 'matches.manage')).toBe(true);
+ expect(guardAllows(user, 'settings.manage')).toBe(true);
+ });
+
+ it('audit list scope: only SUPER_ADMIN is unrestricted', () => {
+ expect(isAuditListUnrestricted('SUPER_ADMIN')).toBe(true);
+ expect(isAuditListUnrestricted('FINANCE_ADMIN')).toBe(false);
+ expect(isAuditListUnrestricted('MATCH_ADMIN')).toBe(false);
+ });
+});
diff --git a/apps/api/src/applications/admin/admin.controller.ts b/apps/api/src/applications/admin/admin.controller.ts
index 39b5597..bd2350d 100644
--- a/apps/api/src/applications/admin/admin.controller.ts
+++ b/apps/api/src/applications/admin/admin.controller.ts
@@ -24,6 +24,7 @@ import { jsonResponse } from '../../shared/common/filters';
import { appBadRequest, appForbidden } from '../../shared/common/app-error';
import { getUploadRoot } from '../../shared/uploads/upload-paths';
import { UsersService } from '../../domains/identity/users.service';
+import { AdminStaffService } from '../../domains/identity/admin-staff.service';
import { AgentsService } from '../../domains/agent/agents.service';
import { WalletService } from '../../domains/ledger/wallet.service';
import { MatchesService } from '../../domains/catalog/matches.service';
@@ -255,6 +256,40 @@ class UpdatePlayerAdminDto {
cashbackRate?: number | null;
}
+class CreateStaffDto {
+ @IsString()
+ username!: string;
+
+ @IsString()
+ @MinLength(8)
+ password!: string;
+
+ @IsString()
+ roleCode!: string;
+}
+
+class UpdateStaffDto {
+ @IsOptional()
+ @IsIn(['ACTIVE', 'SUSPENDED', 'DISABLED'])
+ status?: string;
+
+ @IsOptional()
+ @IsString()
+ roleCode?: string;
+
+ @IsOptional()
+ @IsString()
+ @MinLength(8)
+ password?: string;
+}
+
+class ResetPlayerPasswordDto {
+ @IsOptional()
+ @IsString()
+ @MinLength(8)
+ password?: string;
+}
+
class PlatformDirectCashbackSettingsDto {
@IsOptional()
@IsNumber()
@@ -1168,6 +1203,7 @@ export class AdminController {
private databaseReset: DatabaseResetService,
private smokeTests: SmokeTestService,
private depositService: DepositService,
+ private staff: AdminStaffService,
) {}
@Get('dashboard')
@@ -1178,7 +1214,7 @@ export class AdminController {
}
@Get('users/page-init')
- @RequirePermissions(P.agentsView)
+ @RequirePermissions(P.agentsView, P.usersView)
async getUsersPageInit() {
const [
playerSettings,
@@ -1380,6 +1416,84 @@ export class AdminController {
return jsonResponse(detail);
}
+ @Post('users/:id/reset-password')
+ @RequirePermissions(P.usersResetPassword)
+ async resetPlayerPassword(
+ @CurrentUser('id') operatorId: bigint,
+ @Param('id') id: string,
+ @Body() dto: ResetPlayerPasswordDto,
+ ) {
+ const { password } = await this.staff.resetPlayerPassword(BigInt(id), dto.password);
+ await this.audit.log({
+ operatorId,
+ operatorType: 'ADMIN',
+ action: 'RESET_PLAYER_PASSWORD',
+ module: 'USERS',
+ targetId: id,
+ });
+ const detail = await this.users.getPlayerAdminDetail(BigInt(id));
+ return jsonResponse({ ...detail, password });
+ }
+
+ @Get('staff/roles')
+ @RequirePermissions(P.settings)
+ async listStaffRoles() {
+ const roles = await this.staff.listRoles();
+ return jsonResponse(roles);
+ }
+
+ @Get('staff')
+ @RequirePermissions(P.settings)
+ async listStaff(
+ @Query('page') page?: string,
+ @Query('pageSize') pageSize?: string,
+ @Query('keyword') keyword?: string,
+ ) {
+ const result = await this.staff.listStaff(
+ page ? parseInt(page, 10) : 1,
+ pageSize ? parseInt(pageSize, 10) : 20,
+ keyword,
+ );
+ return jsonResponse(result);
+ }
+
+ @Post('staff')
+ @RequirePermissions(P.settings)
+ async createStaff(
+ @CurrentUser('id') operatorId: bigint,
+ @Body() dto: CreateStaffDto,
+ ) {
+ const created = await this.staff.createStaff(dto);
+ await this.audit.log({
+ operatorId,
+ operatorType: 'ADMIN',
+ action: 'CREATE_STAFF',
+ module: 'STAFF',
+ targetId: created.id,
+ afterData: { username: created.username, role: created.role },
+ });
+ return jsonResponse(created);
+ }
+
+ @Patch('staff/:id')
+ @RequirePermissions(P.settings)
+ async updateStaff(
+ @CurrentUser('id') operatorId: bigint,
+ @Param('id') id: string,
+ @Body() dto: UpdateStaffDto,
+ ) {
+ const updated = await this.staff.updateStaff(BigInt(id), dto);
+ await this.audit.log({
+ operatorId,
+ operatorType: 'ADMIN',
+ action: 'UPDATE_STAFF',
+ module: 'STAFF',
+ targetId: id,
+ afterData: JSON.stringify({ status: dto.status, roleCode: dto.roleCode }),
+ });
+ return jsonResponse(updated);
+ }
+
@Delete('users/:id')
@RequirePermissions(P.usersCreate)
async deletePlayer(
@@ -2803,6 +2917,9 @@ export class AdminController {
@Get('audit-logs')
@RequirePermissions(P.audit)
async auditLogs(
+ @CurrentUser('id') viewerId: bigint,
+ @CurrentUser('role') viewerRole: string | undefined,
+ @CurrentUser('userType') viewerUserType: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('module') module?: string,
@@ -2811,6 +2928,7 @@ export class AdminController {
page ? parseInt(page, 10) : 1,
pageSize ? parseInt(pageSize, 10) : 10,
module || undefined,
+ { viewerId, viewerRole, viewerUserType },
);
return jsonResponse(result);
}
diff --git a/apps/api/src/domains/identity/admin-staff.service.ts b/apps/api/src/domains/identity/admin-staff.service.ts
new file mode 100644
index 0000000..377acef
--- /dev/null
+++ b/apps/api/src/domains/identity/admin-staff.service.ts
@@ -0,0 +1,194 @@
+import { Injectable } from '@nestjs/common';
+import * as bcrypt from 'bcryptjs';
+import { randomBytes } from 'crypto';
+import { PrismaService } from '../../shared/prisma/prisma.service';
+import { appBadRequest, appNotFound } from '../../shared/common/app-error';
+import { ensureUserInviteCode } from '../../shared/common/invite-code.util';
+
+const STAFF_ROLE_CODES = ['SUPER_ADMIN', 'MATCH_ADMIN', 'FINANCE_ADMIN', 'SUPPORT'] as const;
+
+function generatePassword(length = 10): string {
+ const raw = randomBytes(12).toString('base64url').replace(/[^a-zA-Z0-9]/g, '');
+ const base = (raw + 'Aa1').slice(0, Math.max(8, length));
+ return base;
+}
+
+@Injectable()
+export class AdminStaffService {
+ constructor(private prisma: PrismaService) {}
+
+ async listRoles() {
+ const roles = await this.prisma.role.findMany({
+ where: { code: { in: [...STAFF_ROLE_CODES] } },
+ orderBy: { code: 'asc' },
+ select: { id: true, code: true, name: true },
+ });
+ return roles.map((r) => ({
+ id: r.id.toString(),
+ code: r.code,
+ name: r.name,
+ }));
+ }
+
+ async listStaff(page = 1, pageSize = 20, keyword?: string) {
+ const where = {
+ userType: 'ADMIN' as const,
+ deletedAt: null,
+ ...(keyword?.trim()
+ ? { username: { contains: keyword.trim(), mode: 'insensitive' as const } }
+ : {}),
+ };
+ const [total, items] = await Promise.all([
+ this.prisma.user.count({ where }),
+ this.prisma.user.findMany({
+ where,
+ skip: (page - 1) * pageSize,
+ take: pageSize,
+ orderBy: { createdAt: 'desc' },
+ include: {
+ auth: { select: { lastLoginAt: true } },
+ adminRole: { include: { role: { select: { code: true, name: true } } } },
+ },
+ }),
+ ]);
+ return {
+ total,
+ items: items.map((u) => ({
+ id: u.id.toString(),
+ username: u.username,
+ status: u.status,
+ role: u.adminRole?.role?.code ?? null,
+ roleName: u.adminRole?.role?.name ?? null,
+ lastLoginAt: u.auth?.lastLoginAt ?? null,
+ createdAt: u.createdAt,
+ })),
+ };
+ }
+
+ async createStaff(data: { username: string; password: string; roleCode: string }) {
+ const username = data.username.trim();
+ if (!username) throw appBadRequest('USERNAME_REQUIRED');
+ if (data.password.length < 8) throw appBadRequest('PASSWORD_MIN_LENGTH');
+ if (!STAFF_ROLE_CODES.includes(data.roleCode as (typeof STAFF_ROLE_CODES)[number])) {
+ throw appBadRequest('INVALID_ROLE');
+ }
+
+ const existing = await this.prisma.user.findUnique({ where: { username } });
+ if (existing) throw appBadRequest('USERNAME_TAKEN');
+
+ const role = await this.prisma.role.findUnique({ where: { code: data.roleCode } });
+ if (!role) throw appBadRequest('INVALID_ROLE');
+
+ const hash = await bcrypt.hash(data.password, 10);
+ const user = await this.prisma.user.create({
+ data: {
+ username,
+ userType: 'ADMIN',
+ auth: { create: { passwordHash: hash } },
+ adminRole: { create: { roleId: role.id } },
+ },
+ include: {
+ adminRole: { include: { role: { select: { code: true, name: true } } } },
+ },
+ });
+ await ensureUserInviteCode(this.prisma, user.id);
+
+ return {
+ id: user.id.toString(),
+ username: user.username,
+ status: user.status,
+ role: user.adminRole?.role?.code ?? null,
+ roleName: user.adminRole?.role?.name ?? null,
+ };
+ }
+
+ async updateStaff(
+ staffId: bigint,
+ data: { status?: string; roleCode?: string; password?: string },
+ ) {
+ const user = await this.prisma.user.findFirst({
+ where: { id: staffId, userType: 'ADMIN', deletedAt: null },
+ include: { auth: true, adminRole: true },
+ });
+ if (!user) throw appNotFound('USER_NOT_FOUND');
+
+ if (data.status !== undefined) {
+ if (!['ACTIVE', 'SUSPENDED', 'DISABLED'].includes(data.status)) {
+ throw appBadRequest('INVALID_STATUS');
+ }
+ await this.prisma.user.update({
+ where: { id: staffId },
+ data: { status: data.status },
+ });
+ }
+
+ if (data.roleCode !== undefined) {
+ if (!STAFF_ROLE_CODES.includes(data.roleCode as (typeof STAFF_ROLE_CODES)[number])) {
+ throw appBadRequest('INVALID_ROLE');
+ }
+ const role = await this.prisma.role.findUnique({ where: { code: data.roleCode } });
+ if (!role) throw appBadRequest('INVALID_ROLE');
+ if (user.adminRole) {
+ await this.prisma.adminUserRole.update({
+ where: { userId: staffId },
+ data: { roleId: role.id },
+ });
+ } else {
+ await this.prisma.adminUserRole.create({
+ data: { userId: staffId, roleId: role.id },
+ });
+ }
+ }
+
+ let plainPassword: string | undefined;
+ if (data.password !== undefined) {
+ if (data.password.length < 8) throw appBadRequest('PASSWORD_MIN_LENGTH');
+ if (!user.auth) throw appBadRequest('AUTH_INFO_MISSING');
+ plainPassword = data.password;
+ const hash = await bcrypt.hash(data.password, 10);
+ await this.prisma.userAuth.update({
+ where: { userId: staffId },
+ data: { passwordHash: hash, loginFailCount: 0, lockedUntil: null },
+ });
+ }
+
+ const refreshed = await this.prisma.user.findUnique({
+ where: { id: staffId },
+ include: { adminRole: { include: { role: { select: { code: true, name: true } } } } },
+ });
+
+ return {
+ id: refreshed!.id.toString(),
+ username: refreshed!.username,
+ status: refreshed!.status,
+ role: refreshed!.adminRole?.role?.code ?? null,
+ roleName: refreshed!.adminRole?.role?.name ?? null,
+ ...(plainPassword ? { password: plainPassword } : {}),
+ };
+ }
+
+ async resetPlayerPassword(playerId: bigint, password?: string) {
+ const user = await this.prisma.user.findFirst({
+ where: { id: playerId, userType: 'PLAYER', deletedAt: null },
+ include: { auth: true },
+ });
+ if (!user) throw appNotFound('PLAYER_NOT_FOUND');
+ if (!user.auth) throw appBadRequest('AUTH_INFO_MISSING');
+
+ const nextPassword = password?.trim() || generatePassword();
+ if (nextPassword.length < 8) throw appBadRequest('PASSWORD_MIN_LENGTH');
+
+ const hash = await bcrypt.hash(nextPassword, 10);
+ await this.prisma.userAuth.update({
+ where: { userId: playerId },
+ data: { passwordHash: hash, loginFailCount: 0, lockedUntil: null },
+ });
+ await this.prisma.userPreference.upsert({
+ where: { userId: playerId },
+ create: { userId: playerId, managedPassword: nextPassword },
+ update: { managedPassword: nextPassword },
+ });
+
+ return { password: nextPassword };
+ }
+}
diff --git a/apps/api/src/domains/identity/auth.controller.ts b/apps/api/src/domains/identity/auth.controller.ts
index 861bcc4..dd46b54 100644
--- a/apps/api/src/domains/identity/auth.controller.ts
+++ b/apps/api/src/domains/identity/auth.controller.ts
@@ -171,6 +171,7 @@ export class AuthController {
@CurrentUser('userType') userType: string,
@CurrentUser('locale') locale: string | undefined,
@CurrentUser('role') role: string | undefined,
+ @CurrentUser('permissions') permissions: string[] | undefined,
@CurrentUser('agentLevel') agentLevel: number | null | undefined,
) {
const level = userType === 'AGENT' ? agentLevel ?? null : null;
@@ -194,6 +195,7 @@ export class AuthController {
userType,
locale,
role,
+ permissions: userType === 'ADMIN' ? permissions ?? [] : undefined,
agentLevel: level,
maxAgentLevel,
canManageSubAgents,
diff --git a/apps/api/src/domains/identity/auth.service.ts b/apps/api/src/domains/identity/auth.service.ts
index 43b9588..8427687 100644
--- a/apps/api/src/domains/identity/auth.service.ts
+++ b/apps/api/src/domains/identity/auth.service.ts
@@ -98,7 +98,18 @@ export class AuthService {
} else {
user = await this.prisma.user.findUnique({
where: { username: username.trim() },
- include: { auth: true, adminRole: { include: { role: true } } },
+ include: {
+ auth: true,
+ adminRole: {
+ include: {
+ role: {
+ include: {
+ permissions: { include: { permission: true } },
+ },
+ },
+ },
+ },
+ },
});
}
@@ -178,6 +189,15 @@ export class AuthService {
const token = this.jwt.sign(payload, { expiresIn });
+ const rolePerms = user.adminRole?.role as
+ | { permissions?: Array<{ permission: { code: string } }> }
+ | undefined
+ | null;
+ const adminPermissions =
+ user.userType === 'ADMIN'
+ ? (rolePerms?.permissions?.map((rp) => rp.permission.code) ?? [])
+ : undefined;
+
return {
token,
user: {
@@ -187,6 +207,7 @@ export class AuthService {
locale: user.locale,
role: user.adminRole?.role?.code,
agentLevel: user.userType === 'AGENT' ? user.agentLevel : null,
+ ...(adminPermissions ? { permissions: adminPermissions } : {}),
},
};
}
diff --git a/apps/api/src/domains/identity/users.module.ts b/apps/api/src/domains/identity/users.module.ts
index ad4c773..0be573c 100644
--- a/apps/api/src/domains/identity/users.module.ts
+++ b/apps/api/src/domains/identity/users.module.ts
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
+import { AdminStaffService } from './admin-staff.service';
import { AgentsModule } from '../agent/agents.module';
import { CashbackModule } from '../operations/cashback/cashback.module';
@Module({
imports: [AgentsModule, CashbackModule],
- providers: [UsersService],
- exports: [UsersService],
+ providers: [UsersService, AdminStaffService],
+ exports: [UsersService, AdminStaffService],
})
export class UsersModule {}
diff --git a/apps/api/src/domains/operations/audit/audit-list-scope.spec.ts b/apps/api/src/domains/operations/audit/audit-list-scope.spec.ts
new file mode 100644
index 0000000..9c4d8ed
--- /dev/null
+++ b/apps/api/src/domains/operations/audit/audit-list-scope.spec.ts
@@ -0,0 +1,20 @@
+import { isAuditListUnrestricted } from './audit-list-scope';
+
+describe('audit-list-scope', () => {
+ describe('isAuditListUnrestricted', () => {
+ it('allows SUPER_ADMIN to see all logs', () => {
+ expect(isAuditListUnrestricted('SUPER_ADMIN')).toBe(true);
+ });
+
+ it('scopes MATCH_ADMIN, FINANCE_ADMIN, and SUPPORT', () => {
+ expect(isAuditListUnrestricted('MATCH_ADMIN')).toBe(false);
+ expect(isAuditListUnrestricted('FINANCE_ADMIN')).toBe(false);
+ expect(isAuditListUnrestricted('SUPPORT')).toBe(false);
+ });
+
+ it('scopes unknown or missing role', () => {
+ expect(isAuditListUnrestricted(undefined)).toBe(false);
+ expect(isAuditListUnrestricted(null)).toBe(false);
+ });
+ });
+});
diff --git a/apps/api/src/domains/operations/audit/audit-list-scope.ts b/apps/api/src/domains/operations/audit/audit-list-scope.ts
new file mode 100644
index 0000000..6a6b3c8
--- /dev/null
+++ b/apps/api/src/domains/operations/audit/audit-list-scope.ts
@@ -0,0 +1,45 @@
+import { Prisma } from '@prisma/client';
+import { PrismaService } from '../../../shared/prisma/prisma.service';
+
+export type AuditViewerScope = {
+ viewerId: bigint;
+ viewerRole?: string | null;
+ viewerUserType: string;
+};
+
+/** SUPER_ADMIN (and unrestricted legacy admins) see all audit rows. */
+export function isAuditListUnrestricted(role?: string | null): boolean {
+ return role === 'SUPER_ADMIN';
+}
+
+/**
+ * Non–super-admin staff may only see audit rows whose operator shares their admin role,
+ * or (for SUPPORT) player-initiated identity actions.
+ */
+export async function buildAuditListScopeWhere(
+ prisma: PrismaService,
+ scope: AuditViewerScope,
+): Promise {
+ if (isAuditListUnrestricted(scope.viewerRole)) {
+ return undefined;
+ }
+
+ if (!scope.viewerRole) {
+ return { operatorId: scope.viewerId };
+ }
+
+ const sameRoleUsers = await prisma.user.findMany({
+ where: { adminRole: { role: { code: scope.viewerRole } } },
+ select: { id: true },
+ });
+ const sameRoleOperatorIds = sameRoleUsers.map((u) => u.id);
+
+ const or: Prisma.AuditLogWhereInput[] = [{ operatorId: { in: sameRoleOperatorIds } }];
+
+ // SUPPORT handles player account recovery; include player-initiated audit entries.
+ if (scope.viewerRole === 'SUPPORT') {
+ or.push({ operatorType: 'PLAYER' });
+ }
+
+ return { OR: or };
+}
diff --git a/apps/api/src/domains/operations/audit/audit.service.ts b/apps/api/src/domains/operations/audit/audit.service.ts
index 4f0eda1..23bb586 100644
--- a/apps/api/src/domains/operations/audit/audit.service.ts
+++ b/apps/api/src/domains/operations/audit/audit.service.ts
@@ -1,5 +1,7 @@
import { Injectable } from '@nestjs/common';
+import { Prisma } from '@prisma/client';
import { PrismaService } from '../../../shared/prisma/prisma.service';
+import { AuditViewerScope, buildAuditListScopeWhere } from './audit-list-scope';
@Injectable()
export class AuditService {
@@ -31,10 +33,19 @@ export class AuditService {
});
}
- async list(page = 1, pageSize = 10, module?: string) {
+ async list(
+ page = 1,
+ pageSize = 10,
+ module?: string,
+ viewer?: AuditViewerScope,
+ ) {
const skip = (page - 1) * pageSize;
- const where = module ? { module } : {};
- const [items, total] = await Promise.all([
+ const scopeWhere = viewer ? await buildAuditListScopeWhere(this.prisma, viewer) : undefined;
+ const where: Prisma.AuditLogWhereInput = {
+ ...(module ? { module } : {}),
+ ...(scopeWhere ?? {}),
+ };
+ const [rows, total] = await Promise.all([
this.prisma.auditLog.findMany({
where,
orderBy: { createdAt: 'desc' },
@@ -43,6 +54,42 @@ export class AuditService {
}),
this.prisma.auditLog.count({ where }),
]);
+
+ const operatorIds = [
+ ...new Set(rows.map((r) => r.operatorId).filter((id): id is bigint => id != null)),
+ ];
+ const operators =
+ operatorIds.length > 0
+ ? await this.prisma.user.findMany({
+ where: { id: { in: operatorIds } },
+ select: {
+ id: true,
+ username: true,
+ userType: true,
+ adminRole: { select: { role: { select: { code: true } } } },
+ },
+ })
+ : [];
+ const operatorById = new Map(operators.map((u) => [u.id.toString(), u]));
+
+ const items = rows.map((row) => {
+ const op = row.operatorId ? operatorById.get(row.operatorId.toString()) : null;
+ return {
+ id: row.id.toString(),
+ action: row.action,
+ module: row.module,
+ targetType: row.targetType,
+ targetId: row.targetId,
+ operatorId: row.operatorId?.toString() ?? null,
+ operatorUsername: op?.username ?? null,
+ operatorRole: op?.adminRole?.role?.code ?? null,
+ operatorUserType: op?.userType ?? null,
+ operatorType: row.operatorType,
+ ipAddress: row.ipAddress,
+ createdAt: row.createdAt,
+ };
+ });
+
return { items, total, page, pageSize };
}
}
diff --git a/apps/api/src/infrastructure/database/run-seed.ts b/apps/api/src/infrastructure/database/run-seed.ts
index ee27d3c..5e2d436 100644
--- a/apps/api/src/infrastructure/database/run-seed.ts
+++ b/apps/api/src/infrastructure/database/run-seed.ts
@@ -5,6 +5,9 @@ import { ensureUserInviteCode } from '../../shared/common/invite-code.util';
export const DEMO_ACCOUNTS = [
'admin / Admin@123',
+ 'matchadmin / MatchAdmin@123',
+ 'financeadmin / FinanceAdmin@123',
+ 'support1 / Support@123',
'agent1 / Agent@123',
'player1 / Player@123',
] as const;
@@ -40,10 +43,11 @@ async function seedRolesAndConfig() {
});
const permCodes = [
- 'users.create', 'users.view', 'agents.create', 'agents.view', 'agents.credit',
+ 'users.create', 'users.view', 'users.reset_password', 'agents.create', 'agents.view', 'agents.credit',
'wallet.deposit', 'wallet.withdraw', 'matches.manage', 'settlement.confirm',
'settlement.resettle', 'cashback.confirm', 'content.manage', 'reports.view',
'bets.view', 'settings.manage', 'settings.reset_database', 'audit.view',
+ 'deposit.manage', 'deposit.review',
];
const permIds = new Map();
@@ -79,14 +83,17 @@ async function seedRolesAndConfig() {
return role;
}
- await ensureRole('MATCH_ADMIN', 'Match Admin', [
- 'matches.manage', 'settlement.confirm', 'bets.view', 'reports.view', 'audit.view',
+ const matchAdminRole = await ensureRole('MATCH_ADMIN', 'Match Admin', [
+ 'matches.manage', 'settlement.confirm', 'content.manage', 'bets.view', 'reports.view', 'audit.view',
]);
- await ensureRole('FINANCE_ADMIN', 'Finance Admin', [
- 'wallet.deposit', 'wallet.withdraw', 'cashback.confirm', 'agents.view',
+ const financeAdminRole = await ensureRole('FINANCE_ADMIN', 'Finance Admin', [
+ 'wallet.deposit', 'wallet.withdraw', 'cashback.confirm', 'agents.view', 'agents.credit',
+ 'users.view', 'users.create', 'deposit.manage', 'deposit.review',
'reports.view', 'bets.view', 'audit.view',
]);
- await ensureRole('SUPPORT', 'Support', ['users.view', 'bets.view', 'reports.view', 'audit.view']);
+ const supportRole = await ensureRole('SUPPORT', 'Support', [
+ 'users.view', 'users.reset_password', 'bets.view', 'reports.view', 'audit.view',
+ ]);
const defaultBettingLimits = [
['bet.min_stake', '1', '最小单注金额'],
@@ -104,7 +111,7 @@ async function seedRolesAndConfig() {
});
}
- return superAdminRole;
+ return { superAdminRole, matchAdminRole, financeAdminRole, supportRole };
}
async function seedAdminUser(superAdminRole: { id: bigint }) {
@@ -122,6 +129,51 @@ async function seedAdminUser(superAdminRole: { id: bigint }) {
});
}
+async function seedStaffDemoUser(
+ username: string,
+ password: string,
+ roleId: bigint,
+) {
+ const hash = await bcrypt.hash(password, 10);
+ const user = await prisma.user.upsert({
+ where: { username },
+ create: {
+ username,
+ userType: 'ADMIN',
+ auth: { create: { passwordHash: hash } },
+ adminRole: { create: { roleId } },
+ },
+ update: {
+ userType: 'ADMIN',
+ status: 'ACTIVE',
+ },
+ });
+ await prisma.userAuth.upsert({
+ where: { userId: user.id },
+ create: { userId: user.id, passwordHash: hash },
+ update: {
+ passwordHash: hash,
+ loginFailCount: 0,
+ lockedUntil: null,
+ },
+ });
+ await prisma.adminUserRole.upsert({
+ where: { userId: user.id },
+ create: { userId: user.id, roleId },
+ update: { roleId },
+ });
+}
+
+async function seedDevStaffUsers(roles: {
+ matchAdminRole: { id: bigint };
+ financeAdminRole: { id: bigint };
+ supportRole: { id: bigint };
+}) {
+ await seedStaffDemoUser('matchadmin', 'MatchAdmin@123', roles.matchAdminRole.id);
+ await seedStaffDemoUser('financeadmin', 'FinanceAdmin@123', roles.financeAdminRole.id);
+ await seedStaffDemoUser('support1', 'Support@123', roles.supportRole.id);
+}
+
async function seedDevDemoUsers() {
const agentHash = await bcrypt.hash('Agent@123', 10);
const playerHash = await bcrypt.hash('Player@123', 10);
@@ -356,10 +408,11 @@ export async function runSeed(client: PrismaClient, options?: RunSeedOptions) {
const mode = resolveSeedMode(options);
console.log(`Seeding database (mode=${mode})...`);
- const superAdminRole = await seedRolesAndConfig();
- await seedAdminUser(superAdminRole);
+ const roles = await seedRolesAndConfig();
+ await seedAdminUser(roles.superAdminRole);
if (mode === 'dev') {
+ await seedDevStaffUsers(roles);
await seedDevDemoUsers();
}
diff --git a/apps/player/src/components/outright/OutrightPanel.vue b/apps/player/src/components/outright/OutrightPanel.vue
index 64b6237..de4ccd7 100644
--- a/apps/player/src/components/outright/OutrightPanel.vue
+++ b/apps/player/src/components/outright/OutrightPanel.vue
@@ -1,5 +1,5 @@