feat: мультироль
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 9s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 2m6s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 26s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 6s

This commit is contained in:
2026-05-11 21:29:16 +03:00
parent 3b0bbfc858
commit 6824d7ce7d
29 changed files with 1350 additions and 95 deletions
+2 -2
View File
@@ -70,8 +70,8 @@ export const usersApi = {
)
return extractItems(payload)
},
setRole: (id: string | number, role: 'Student' | 'Teacher' | 'Admin') =>
apiRequest<void>(`/users/${id}/role`, { method: 'PATCH', body: JSON.stringify(role) }),
setRole: (id: string | number, roles: Array<'Student' | 'Teacher' | 'Admin'>) =>
apiRequest<void>(`/users/${id}/role`, { method: 'PATCH', body: JSON.stringify(roles) }),
setActive: (id: string | number, isActive: boolean) =>
apiRequest<void>(`/users/${id}/active`, { method: 'PATCH', body: JSON.stringify(isActive) }),
}
+15 -1
View File
@@ -16,12 +16,26 @@ export function mapApiRole(role: string | undefined): UserRole {
return 'student'
}
function mapApiRoles(roles: string[] | undefined): UserRole[] {
if (!roles?.length) return ['student']
return Array.from(new Set(roles.map(mapApiRole)))
}
function getDefaultActiveRole(roles: UserRole[]): UserRole {
if (roles.includes('student')) return 'student'
if (roles.includes('teacher')) return 'teacher'
if (roles.includes('admin')) return 'admin'
return 'student'
}
export function mapApiUser(user: UserAuthDto | UserDto, stats?: UserStatsDto): User {
const roles = mapApiRoles(user.roles)
return {
id: String(user.id),
name: user.displayName || user.email || 'Пользователь UniVerse',
email: user.email || '',
role: mapApiRole(user.role),
roles,
activeRole: getDefaultActiveRole(roles),
avatar: 'avatarUrl' in user ? user.avatarUrl ?? undefined : undefined,
institute: 'ЮФУ',
department: '',
+1 -1
View File
@@ -32,7 +32,7 @@ export interface UserAuthDto {
id: number
email: string
displayName?: string | null
role: ApiUserRole
roles: ApiUserRole[]
}
export interface UserDto extends UserAuthDto {
@@ -8,7 +8,7 @@ const auth = useAuthStore()
const route = useRoute()
const navItems = computed(() => {
const role = auth.user?.role ?? 'student'
const role = auth.user?.activeRole ?? 'student'
if (role === 'teacher') return [
{ label: 'Дашборд', icon: 'chart-bar', to: '/teacher' },
{ label: 'Лекции', icon: 'book-2', to: '/teacher/lectures' },
+50 -2
View File
@@ -9,6 +9,7 @@ const router = useRouter()
const route = useRoute()
interface NavItem { label: string; icon: string; to: string; roles: string[] }
type AppRole = 'student' | 'teacher' | 'admin'
const navItems: NavItem[] = [
{ label: 'Главная', icon: 'home', to: '/', roles: ['student'] },
@@ -30,9 +31,32 @@ const navItems: NavItem[] = [
]
const visible = computed(() =>
navItems.filter(n => auth.user && n.roles.includes(auth.user.role))
navItems.filter(n => auth.user && n.roles.includes(auth.user.activeRole))
)
const roleButtons = computed(() => {
if (!auth.user) return []
const labels: Record<AppRole, string> = {
student: 'Студент',
teacher: 'Преподаватель',
admin: 'Администратор',
}
const targets: Record<AppRole, string> = {
student: '/',
teacher: '/teacher',
admin: '/admin',
}
return auth.user.roles
.filter(role => role !== auth.user?.activeRole)
.map(role => ({ role, label: labels[role], to: targets[role] }))
})
function switchToRole(role: AppRole, to: string) {
if (auth.setActiveRole(role)) {
router.push(to)
}
}
function isActive(to: string) {
if (to === '/') return route.path === '/'
return route.path.startsWith(to) && to !== '/'
@@ -55,6 +79,16 @@ function isActive(to: string) {
</nav>
<div class="sidebar-footer">
<div class="role-switches" v-if="roleButtons.length">
<button
v-for="item in roleButtons"
:key="item.role"
class="role-switch-btn"
@click="switchToRole(item.role, item.to)"
>
Перейти: {{ item.label }}
</button>
</div>
<button class="logout-btn" @click="auth.logout().then(() => router.push('/login'))">
<AppIcon class="logout-icon" icon="logout" :size="16" />
Выйти
@@ -110,7 +144,21 @@ function isActive(to: string) {
box-shadow: 0 2px 8px rgba(34,197,94,0.12);
}
.nav-icon { flex-shrink: 0; color: currentColor; }
.sidebar-footer { padding: 10px 18px 8px; }
.sidebar-footer { padding: 10px 18px 8px; display: flex; flex-direction: column; gap: 8px; }
.role-switches { display: flex; flex-direction: column; gap: 6px; }
.role-switch-btn {
width: 100%;
background: rgba(34,197,94,0.08);
border: 1px solid rgba(34,197,94,0.2);
border-radius: var(--radius-sm);
padding: 8px 10px;
font-size: 12px;
font-weight: 600;
color: var(--color-primary-dark);
cursor: pointer;
transition: all 0.2s;
}
.role-switch-btn:hover { background: rgba(34,197,94,0.15); }
.logout-btn {
width: 100%;
background: rgba(239,68,68,0.08);
+6 -16
View File
@@ -21,13 +21,6 @@ const roleLabels: Record<string, string> = {
const unreadCount = computed(() => userStore.unreadCount())
function switchRole() {
auth.switchRole()
if (auth.user?.role === 'teacher') router.push('/teacher')
else if (auth.user?.role === 'admin') router.push('/admin')
else router.push('/')
}
function openProfile() {
router.push('/profile')
}
@@ -47,9 +40,9 @@ function openProfile() {
<div class="topbar-right">
<CoinChip v-if="auth.user" :amount="auth.user.coins" />
<button class="role-btn" @click="switchRole" v-if="auth.user">
{{ roleLabels[auth.user.role] }}
</button>
<span class="role-chip" v-if="auth.user">
{{ roleLabels[auth.user.activeRole] }}
</span>
<button class="notif-btn" @click="$router.push('/notifications')">
<AppIcon icon="bell" :size="18" />
@@ -115,19 +108,16 @@ function openProfile() {
gap: 12px;
flex-shrink: 0;
}
.role-btn {
.role-chip {
background: rgba(34,197,94,0.12);
border: 1px solid rgba(34,197,94,0.3);
border-radius: var(--radius-sm);
padding: 5px 10px;
font-size: 12px;
font-weight: 600;
color: var(--color-primary-dark);
cursor: pointer;
transition: all 0.2s;
color: var(--color-primary-dark);
white-space: nowrap;
}
.role-btn:hover { background: rgba(34,197,94,0.2); }
.notif-btn {
position: relative;
background: none;
@@ -173,6 +163,6 @@ function openProfile() {
.topbar-center { display: none; }
.brand-name { display: none; }
.avatar-name { display: none; }
.role-btn { display: none; }
.role-chip { display: none; }
}
</style>
+7 -4
View File
@@ -39,16 +39,19 @@ const router = createRouter({
router.beforeEach(async (to) => {
const auth = useAuthStore()
const resolveDefaultRoute = () => {
if (auth.user?.activeRole === 'teacher') return '/teacher'
if (auth.user?.activeRole === 'admin') return '/admin'
return '/'
}
if (!auth.initialized && !to.meta.public) {
await auth.initialize()
}
if (!to.meta.public && !auth.isAuthenticated) {
return '/login'
}
if (to.meta.role && auth.user && auth.user.role !== to.meta.role) {
if (auth.user.role === 'teacher') return '/teacher'
if (auth.user.role === 'admin') return '/admin'
return '/'
if (to.meta.role && auth.user && !auth.user.roles.includes(to.meta.role as 'student' | 'teacher' | 'admin')) {
return resolveDefaultRoute()
}
})
+6 -4
View File
@@ -74,7 +74,7 @@ export const useAuthStore = defineStore('auth', () => {
return true
}
async function completeMicrosoftLogin(code: string, state: string | null) {
async function completeMicrosoftLogin(code: string, _state: string | null) {
loading.value = true
error.value = null
try {
@@ -136,8 +136,10 @@ export const useAuthStore = defineStore('auth', () => {
user.value = nextUser
}
function switchRole() {
error.value = 'Смена роли доступна только через backend.'
function setActiveRole(role: User['activeRole']) {
if (!user.value || !user.value.roles.includes(role)) return false
user.value = { ...user.value, activeRole: role }
return true
}
return {
@@ -154,6 +156,6 @@ export const useAuthStore = defineStore('auth', () => {
logout,
clearSession,
setUser,
switchRole,
setActiveRole,
}
})
+2 -1
View File
@@ -4,7 +4,8 @@ export interface User {
id: string
name: string
email: string
role: UserRole
roles: UserRole[]
activeRole: UserRole
avatar?: string
institute?: string
department?: string
+23 -7
View File
@@ -25,14 +25,20 @@ const columns = [
const roleLabels = { Student: 'Студент', Teacher: 'Преподаватель', Admin: 'Администратор' } as const
const roleApi = { Студент: 'Student', Преподаватель: 'Teacher', Администратор: 'Admin' } as const
type ApiUserRole = 'Student' | 'Teacher' | 'Admin'
const roleSetSequence: ApiUserRole[][] = [
['Student'],
['Student', 'Teacher'],
['Student', 'Teacher', 'Admin'],
]
const rows = computed(() =>
users.value.map(user => ({
id: user.id,
name: user.displayName || user.email,
email: user.email,
role: roleLabels[user.role],
apiRole: user.role,
role: user.roles.map(role => roleLabels[role]).join(', '),
apiRoles: user.roles,
institute: 'ЮФУ',
activity: user.isActive ? 'Активен' : 'Заблокирован',
isActive: user.isActive,
@@ -56,14 +62,24 @@ async function fetchUsers() {
}
}
async function toggleActive(row: Record<string, any>) {
await usersApi.setActive(row.id, !row.isActive)
async function toggleActive(row: Record<string, unknown>) {
const id = Number(row.id)
const isActive = Boolean(row.isActive)
await usersApi.setActive(id, !isActive)
await fetchUsers()
}
async function promoteRole(row: Record<string, any>) {
const next = row.apiRole === 'Student' ? 'Teacher' : row.apiRole === 'Teacher' ? 'Admin' : 'Student'
await usersApi.setRole(row.id, next)
async function promoteRole(row: Record<string, unknown>) {
const id = Number(row.id)
const apiRoles = (Array.isArray(row.apiRoles) ? row.apiRoles : []) as ApiUserRole[]
const currentKey = [...new Set(apiRoles)].sort().join(',')
const currentIndex = roleSetSequence.findIndex(set => set.slice().sort().join(',') === currentKey)
const next: ApiUserRole[] = (
currentIndex >= 0
? roleSetSequence[(currentIndex + 1) % roleSetSequence.length]
: roleSetSequence[0]
) ?? ['Student']
await usersApi.setRole(id, next)
await fetchUsers()
}
+1 -1
View File
@@ -22,7 +22,7 @@ onMounted(async () => {
else throw new Error('Microsoft не вернул токен авторизации.')
window.history.replaceState({}, document.title, window.location.pathname)
const role = auth.user?.role
const role = auth.user?.activeRole
if (role === 'teacher') await router.replace('/teacher')
else if (role === 'admin') await router.replace('/admin')
else await router.replace('/')