Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions backend/src/modules/admin/admin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,12 @@ export async function listUsers() {
orderBy: { createdAt: 'desc' },
});

const superadminEmail = config.SUPERADMIN_EMAIL;

return users.map(({ createdWorkspaces, _count, ...u }) => ({
...u,
isSuperadmin: u.isSuperadmin || u.email === superadminEmail,
isSuperadminLocked: u.email === superadminEmail,
stats: {
workspaces: _count.createdWorkspaces,
boards: createdWorkspaces.reduce((s, ws) => s + ws._count.boards, 0),
Expand All @@ -47,6 +51,14 @@ export async function listUsers() {
export async function setUserSuperadmin(actorId: string, userId: string, isSuperadmin: boolean) {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new AppError(404, 'Пользователь не найден');

if (!isSuperadmin && user.email === config.SUPERADMIN_EMAIL) {
throw new AppError(403, 'Нельзя снять роль суперадминистратора с резервного аккаунта');
}
if (!isSuperadmin && actorId === userId) {
throw new AppError(403, 'Нельзя снять роль суперадминистратора с самого себя');
}

const updated = await prisma.user.update({
where: { id: userId },
data: { isSuperadmin },
Expand Down
3 changes: 2 additions & 1 deletion backend/src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
return trimmed.split(/\s+/)[0] || trimmed;
}

async function checkBruteForce(email: string): Promise<void> {

Check warning on line 21 in backend/src/modules/auth/auth.service.ts

View workflow job for this annotation

GitHub Actions / Lint & TypeScript

'checkBruteForce' is defined but never used. Allowed unused vars must match /^_/u

Check warning on line 21 in backend/src/modules/auth/auth.service.ts

View workflow job for this annotation

GitHub Actions / Lint & TypeScript

'checkBruteForce' is defined but never used. Allowed unused vars must match /^_/u
if (!await isRedisAvailable()) return; // brute-force protection degraded gracefully
const key = `auth:fail:${email.toLowerCase()}`;
const attempts = (await getCachedJson<number>(key)) ?? 0;
Expand Down Expand Up @@ -117,7 +117,8 @@
}

// Block local login for SSO-only users (except superadmins who always retain local access)
if (user.ssoOnly && !user.isSuperadmin) {
const isSuperadminEffective = user.isSuperadmin || user.email === config.SUPERADMIN_EMAIL;
if (user.ssoOnly && !isSuperadminEffective) {
throw new AppError(403, 'Вход по паролю недоступен. Используйте SSO.');
}

Expand Down
116 changes: 99 additions & 17 deletions frontend/src/components/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,23 +28,40 @@ function GridIcon() {
}

// ─── User dropdown menu ───────────────────────────────────────────────────────
function UserMenu({ user, onLogout, onProfile, onSettings, hasSettings, onAdminUsers, isSuperadmin, navBg, border, textPrimary, textMuted, onClose }: {
function UserMenu({ user, onLogout, onProfile, onSettings, workspaces, current, onAdminUsers, isSuperadmin, navBg, border, textPrimary, textMuted, onClose }: {
user: { name: string; email?: string };
onLogout: () => void;
onProfile: () => void;
onSettings: () => void;
hasSettings: boolean;
onSettings: (slug: string) => void;
workspaces: Array<{ id: string; name: string; slug: string }>;
current: { id: string; name: string; slug: string } | null;
onAdminUsers: () => void;
isSuperadmin: boolean;
navBg: string; border: string; textPrimary: string; textMuted: string;
onClose: () => void;
}) {
const [pickerOpen, setPickerOpen] = useState(false);
const menuBg = navBg === '#0A0D1A' ? '#0F1320' : '#FFFFFF';
const hasWorkspaces = workspaces.length > 0;

useEffect(() => { setPickerOpen(false); }, [current?.id]);

function handleSettingsClick() {
if (current) {
onSettings(current.slug);
onClose();
} else {
setPickerOpen(v => !v);
}
}

return (
<div
role="menu"
aria-label="Меню пользователя"
style={{
backgroundColor: menuBg, border: `1px solid ${border}`, borderRadius: 10,
boxShadow: '0 8px 32px rgba(0,0,0,0.3)', minWidth: 200,
boxShadow: '0 8px 32px rgba(0,0,0,0.3)', minWidth: 220,
padding: '6px 0', position: 'absolute', right: 0, top: 'calc(100% + 8px)', zIndex: 200,
}}
onClick={e => e.stopPropagation()}
Expand All @@ -61,33 +78,93 @@ function UserMenu({ user, onLogout, onProfile, onSettings, hasSettings, onAdminU
)}
</div>
<div style={{ backgroundColor: border, height: 1, margin: '4px 0' }}/>
<button onClick={() => { onProfile(); onClose(); }} style={{
<button role="menuitem" onClick={() => { onProfile(); onClose(); }} style={{
background: 'none', border: 'none', borderRadius: 6, color: textMuted,
cursor: 'pointer', display: 'block', fontFamily: '"Inter", system-ui, sans-serif',
fontSize: 13, padding: '8px 16px', textAlign: 'left', width: '100%',
}}>
Профиль
</button>
{isSuperadmin && (
<button onClick={() => { onAdminUsers(); onClose(); }} style={{
<button role="menuitem" onClick={() => { onAdminUsers(); onClose(); }} style={{
background: 'none', border: 'none', borderRadius: 6, color: '#4F6EF7',
cursor: 'pointer', display: 'block', fontFamily: '"Inter", system-ui, sans-serif',
fontSize: 13, padding: '8px 16px', textAlign: 'left', width: '100%', fontWeight: 500,
}}>
Пользователи
</button>
)}
{hasSettings && (
<button onClick={() => { onSettings(); onClose(); }} style={{
background: 'none', border: 'none', borderRadius: 6, color: textMuted,
cursor: 'pointer', display: 'block', fontFamily: '"Inter", system-ui, sans-serif',
fontSize: 13, padding: '8px 16px', textAlign: 'left', width: '100%',
}}>
Настройки workspace
</button>
{hasWorkspaces && (
<>
<button
role="menuitem"
aria-expanded={!current ? pickerOpen : undefined}
aria-haspopup={!current ? 'listbox' : undefined}
onClick={handleSettingsClick}
style={{
alignItems: 'center', background: 'none', border: 'none', borderRadius: 6,
color: textMuted, cursor: 'pointer', display: 'flex', justifyContent: 'space-between',
fontFamily: '"Inter", system-ui, sans-serif', fontSize: 13,
padding: '8px 16px', textAlign: 'left', width: '100%',
}}
>
<span>Настройки пространства</span>
{!current && (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true" style={{ flexShrink: 0, transform: pickerOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }}>
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
</button>
{/* Inline picker — shown when no active workspace */}
{pickerOpen && !current && (
<div role="listbox" aria-label="Выберите пространство" style={{ borderTop: `1px solid ${border}`, maxHeight: 180, overflowY: 'auto', paddingBottom: 4 }}>
{workspaces.map(ws => (
<button role="option" aria-selected={false} key={ws.id} onClick={() => { onSettings(ws.slug); onClose(); }} style={{
background: 'none', border: 'none', borderRadius: 6, color: textMuted,
cursor: 'pointer', display: 'block', fontFamily: '"Inter", system-ui, sans-serif',
fontSize: 12, padding: '6px 16px 6px 28px', textAlign: 'left', width: '100%',
}}>
{ws.name}
</button>
))}
</div>
)}
{/* When in a workspace — offer switching to another space's settings */}
{current && (
<button
role="menuitem"
aria-expanded={pickerOpen}
onClick={() => setPickerOpen(v => !v)}
style={{
alignItems: 'center', background: 'none', border: 'none', borderRadius: 6,
color: textMuted, cursor: 'pointer', display: 'flex', gap: 4,
fontFamily: '"Inter", system-ui, sans-serif', fontSize: 11,
opacity: 0.6, padding: '2px 16px 6px', textAlign: 'left', width: '100%',
}}
>
Другое пространство
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden="true" style={{ flexShrink: 0, transform: pickerOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }}>
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
)}
{pickerOpen && current && (
<div role="listbox" aria-label="Выберите пространство" style={{ borderTop: `1px solid ${border}`, maxHeight: 180, overflowY: 'auto', paddingBottom: 4 }}>
{workspaces.filter(ws => ws.id !== current.id).map(ws => (
<button role="option" aria-selected={false} key={ws.id} onClick={() => { onSettings(ws.slug); onClose(); }} style={{
background: 'none', border: 'none', borderRadius: 6, color: textMuted,
cursor: 'pointer', display: 'block', fontFamily: '"Inter", system-ui, sans-serif',
fontSize: 12, padding: '6px 16px 6px 28px', textAlign: 'left', width: '100%',
}}>
{ws.name}
</button>
))}
</div>
)}
</>
)}
<div style={{ backgroundColor: border, height: 1, margin: '4px 0' }}/>
<button onClick={() => { onLogout(); onClose(); }} style={{
<button role="menuitem" onClick={() => { onLogout(); onClose(); }} style={{
background: 'none', border: 'none', borderRadius: 6, color: '#F87171',
cursor: 'pointer', display: 'block', fontFamily: '"Inter", system-ui, sans-serif',
fontSize: 13, padding: '8px 16px', textAlign: 'left', width: '100%',
Expand Down Expand Up @@ -230,6 +307,10 @@ export default function AppLayout({ children }: Props) {
e.preventDefault();
setSearchOpen(v => !v);
}
if (e.key === 'Escape') {
setUserMenuOpen(false);
setWsMenuOpen(false);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
Expand Down Expand Up @@ -436,8 +517,9 @@ export default function AppLayout({ children }: Props) {
user={user}
onLogout={handleLogout}
onProfile={() => navigate('/profile')}
onSettings={() => current && navigate(`/w/${current.slug}/settings`)}
hasSettings={!!current}
onSettings={(slug) => navigate(`/w/${slug}/settings`)}
workspaces={workspaces}
current={current}
onAdminUsers={() => navigate('/admin/users')}
isSuperadmin={!!user.isSuperadmin}
navBg={navBg} border={navBorder} textPrimary={wsSelectorText} textMuted={tabIdleText}
Expand Down
15 changes: 11 additions & 4 deletions frontend/src/pages/AdminUsersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export default function AdminUsersPage() {
const bp = useBreakpoint();
const isMobile = bp === 'mobile';
const [hoveredRow, setHoveredRow] = useState<string | null>(null);
const { user } = useAuthStore();
const { user, loading: authLoading } = useAuthStore();
const navigate = useNavigate();
const C = mode === 'dark' ? DARK : LIGHT;

Expand All @@ -153,10 +153,10 @@ export default function AdminUsersPage() {
const [togglingId, setTogglingId] = useState<string | null>(null);

useEffect(() => {
if (!user?.isSuperadmin) {
if (!authLoading && !user?.isSuperadmin) {
navigate('/workspaces');
}
}, [user, navigate]);
}, [user, authLoading, navigate]);

useEffect(() => {
if (tab === 'users') loadUsers();
Expand Down Expand Up @@ -333,7 +333,7 @@ export default function AdminUsersPage() {
Суперадмин
</span>
)}
{u.id !== user?.id && (
{u.id !== user?.id && !u.isSuperadminLocked && (
<button
onClick={() => handleToggleSuperadmin(u)}
disabled={togglingId === u.id}
Expand All @@ -347,6 +347,13 @@ export default function AdminUsersPage() {
{u.isSuperadmin ? 'Снять' : 'Назначить'}
</button>
)}
{u.isSuperadminLocked && (
<span title="Резервный аккаунт — роль нельзя снять" style={{
fontSize: 10, color: C.muted, opacity: 0.5, ...font,
}}>
🔒
</span>
)}
</div>
</div>
))}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export interface AdminUser {
lastLoginAt?: string;
createdAt: string;
isSuperadmin: boolean;
isSuperadminLocked?: boolean;
stats?: AdminUserStats;
}

Expand Down
2 changes: 2 additions & 0 deletions specs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
| **gap-19** | **api-key-audit** | **fix / security** | **P1** | **done** | эта ветка |
| **gap-20** | **config-change-audit** | **fix / security** | **P1** | **done** | эта ветка |
| **gap-21** | **validation-error-audit** | **fix / security** | **P2** | **done** | эта ветка |
| **gap-22** | **workspace-settings-always-accessible** | **feat / ux** | **P2** | **done** | PR #159 |
| **gap-23** | **superadmin-email-badge** | **fix** | **P1** | **done** | PR #159 |

---

Expand Down
Loading
Loading