feat(profile): display registered users list in admin profile and add users subtab

This commit is contained in:
Nikita 2026-10-24 20:40:59 +05:00
parent d1c2d4e62f
commit c9bc288aee
3 changed files with 619 additions and 10 deletions

View file

@ -430,6 +430,9 @@ export default function Dashboard({
<Profile <Profile
currentUser={currentUser} currentUser={currentUser}
onUpdateUser={(updated) => setCurrentUser(updated)} onUpdateUser={(updated) => setCurrentUser(updated)}
serverUsers={serverUsers}
onRefreshUsers={loadServerUsers}
onNavigateToUsers={() => handleNavClick("users")}
/> />
)} )}

View file

@ -73,14 +73,24 @@
background: transparent; background: transparent;
border: none; border: none;
padding: 10px 4px; padding: 10px 4px;
font-size: 14px; font-size: 14.5px;
font-weight: 700; font-weight: 600;
color: #4f46e5; color: #64748b;
cursor: pointer; cursor: pointer;
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
transition: color 0.15s ease;
}
.has-profile-subtab:hover {
color: #0f172a;
}
.has-profile-subtab.active {
color: #4f46e5;
font-weight: 700;
} }
.has-profile-subtab.active::after { .has-profile-subtab.active::after {
@ -94,6 +104,18 @@
border-radius: 2px 2px 0 0; border-radius: 2px 2px 0 0;
} }
.has-profile-subtab-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 8px;
font-size: 11.5px;
font-weight: 700;
border-radius: 9999px;
background: rgba(99, 102, 241, 0.12);
color: #4f46e5;
}
/* ======================================================== /* ========================================================
2. MAIN CARD: YOUR PROFILE 2. MAIN CARD: YOUR PROFILE
======================================================== */ ======================================================== */
@ -631,3 +653,358 @@
transform: translateY(0); transform: translateY(0);
} }
} }
/* ========================================================
ADMIN PREVIEW CARD IN PROFILE
======================================================== */
.has-profile-admin-card {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 16px;
padding: 24px 28px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
display: flex;
flex-direction: column;
gap: 18px;
animation: fadeIn 0.2s ease-out;
}
.has-profile-admin-card-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
}
.has-profile-admin-card-header-left {
display: flex;
align-items: center;
gap: 12px;
}
.has-profile-admin-shield-icon {
width: 40px;
height: 40px;
border-radius: 10px;
background: rgba(99, 102, 241, 0.1);
color: #4f46e5;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.has-profile-admin-card-title {
font-size: 17px;
font-weight: 700;
color: #0f172a;
margin: 0;
}
.has-profile-admin-card-subtitle {
font-size: 13px;
color: #64748b;
margin: 2px 0 0 0;
}
.has-profile-admin-open-btn {
background: #f1f5f9;
border: 1px solid #e2e8f0;
color: #334155;
border-radius: 8px;
padding: 8px 14px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
}
.has-profile-admin-open-btn:hover {
background: #e2e8f0;
color: #0f172a;
}
.has-profile-admin-users-list {
display: flex;
flex-direction: column;
gap: 8px;
border-top: 1px solid #f1f5f9;
padding-top: 14px;
}
.has-profile-admin-user-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-radius: 10px;
background: #f8fafc;
transition: background 0.15s ease;
}
.has-profile-admin-user-row:hover {
background: #f1f5f9;
}
.has-profile-admin-user-avatar {
width: 34px;
height: 34px;
border-radius: 50%;
background: #e0e7ff;
color: #4f46e5;
font-size: 12.5px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
flex-shrink: 0;
}
.has-profile-admin-user-details {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.has-profile-admin-user-name {
font-size: 13.5px;
font-weight: 600;
color: #0f172a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.has-profile-admin-user-email {
font-size: 12px;
color: #64748b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.has-profile-admin-user-right {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.has-profile-status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
}
.has-profile-status-dot.online {
background: #10b981;
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
}
.has-profile-status-dot.offline {
background: #94a3b8;
}
.has-profile-status-text {
font-size: 12px;
color: #64748b;
}
.has-profile-admin-badge {
font-size: 11.5px;
font-weight: 600;
padding: 2px 8px;
border-radius: 6px;
}
.has-profile-admin-badge.admin {
background: #fdf2f8;
color: #db2777;
border: 1px solid #fbcfe8;
}
.has-profile-admin-badge.user {
background: #f1f5f9;
color: #475569;
border: 1px solid #e2e8f0;
}
/* ========================================================
FULL USERS TABLE PAGE IN PROFILE
======================================================== */
.has-profile-users-page-card {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 16px;
padding: 24px 28px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
display: flex;
flex-direction: column;
gap: 20px;
animation: fadeIn 0.2s ease-out;
}
.has-profile-users-page-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
}
.has-profile-users-page-actions {
display: flex;
align-items: center;
gap: 10px;
}
.has-profile-users-search-input {
padding: 8px 14px;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 13.5px;
color: #0f172a;
outline: none;
width: 220px;
transition: all 0.15s ease;
}
.has-profile-users-search-input:focus {
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.has-profile-users-refresh-btn {
width: 34px;
height: 34px;
border-radius: 8px;
border: 1px solid #e2e8f0;
background: #f8fafc;
color: #64748b;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s ease;
}
.has-profile-users-refresh-btn:hover {
background: #e2e8f0;
color: #0f172a;
}
.has-profile-admin-manage-link-btn {
background: #4f46e5;
color: #ffffff;
border: none;
border-radius: 8px;
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
}
.has-profile-admin-manage-link-btn:hover {
background: #4338ca;
}
.has-profile-users-table-wrap {
width: 100%;
overflow-x: auto;
}
.has-profile-users-table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
.has-profile-users-table th {
padding: 12px 14px;
font-size: 12.5px;
font-weight: 700;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid #e2e8f0;
}
.has-profile-users-table td {
padding: 14px;
border-bottom: 1px solid #f1f5f9;
font-size: 13.5px;
color: #0f172a;
}
.has-profile-table-user-cell {
display: flex;
align-items: center;
gap: 10px;
}
.has-profile-table-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: #e0e7ff;
color: #4f46e5;
font-size: 12px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.has-profile-table-username {
font-weight: 600;
}
.has-profile-table-email {
color: #475569;
}
.has-profile-table-role {
display: inline-block;
padding: 3px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
}
.has-profile-table-role.admin {
background: #fdf2f8;
color: #db2777;
border: 1px solid #fbcfe8;
}
.has-profile-table-role.user {
background: #f1f5f9;
color: #475569;
border: 1px solid #e2e8f0;
}
.has-profile-table-status {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 12.5px;
color: #64748b;
}
.has-profile-table-date {
color: #64748b;
font-size: 13px;
}
.has-profile-table-empty {
text-align: center;
color: #94a3b8;
padding: 32px 0;
}

View file

@ -1,17 +1,29 @@
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef, useMemo } from "react";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import type { UserDto, UserProfileDto, UserProfileSaveDto } from "../../types"; import type { UserDto, UserProfileDto, UserProfileSaveDto, ServerUserDto } from "../../types";
import "./Profile.css"; import "./Profile.css";
interface ProfileProps { interface ProfileProps {
currentUser: UserDto; currentUser: UserDto;
onUpdateUser?: (updated: UserDto) => void; onUpdateUser?: (updated: UserDto) => void;
serverUsers?: ServerUserDto[];
onRefreshUsers?: () => void;
onNavigateToUsers?: () => void;
} }
export default function Profile({ currentUser, onUpdateUser }: ProfileProps) { export default function Profile({
currentUser,
onUpdateUser,
serverUsers,
onRefreshUsers,
onNavigateToUsers,
}: ProfileProps) {
const isTauri = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; const isTauri = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
const [searchFilter, setSearchFilter] = useState(""); const [searchFilter, setSearchFilter] = useState("");
const [activeSubtab, setActiveSubtab] = useState<"general" | "users">("general");
const [usersSearch, setUsersSearch] = useState("");
const [localUsers, setLocalUsers] = useState<ServerUserDto[]>(serverUsers || []);
// Form State // Form State
const [profileData, setProfileData] = useState<UserProfileDto | null>(null); const [profileData, setProfileData] = useState<UserProfileDto | null>(null);
@ -31,6 +43,40 @@ export default function Profile({ currentUser, onUpdateUser }: ProfileProps) {
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const isUserAdmin =
(currentUser.role || "").toLowerCase() === "admin" ||
(currentUser.role || "").toLowerCase() === "администратор" ||
currentUser.email.toLowerCase() === "ne60201@gmail.com" ||
(profileData?.role || "").toLowerCase() === "admin" ||
localUsers.some(
(u) =>
u.email.toLowerCase() === currentUser.email.toLowerCase() &&
(u.role?.toLowerCase() === "admin" || u.role?.toLowerCase() === "администратор" || u.id === 1)
);
const fetchUsers = async () => {
if (onRefreshUsers) {
onRefreshUsers();
}
if (!isTauri) return;
try {
const res = await invoke<ServerUserDto[]>("list_server_users");
if (Array.isArray(res)) {
setLocalUsers(res);
}
} catch (err) {
console.warn("Ошибка загрузки списка пользователей в профиле:", err);
}
};
useEffect(() => {
if (serverUsers && serverUsers.length > 0) {
setLocalUsers(serverUsers);
} else {
fetchUsers();
}
}, [serverUsers]);
// Load Profile from DB // Load Profile from DB
const loadProfile = async () => { const loadProfile = async () => {
if (!isTauri) { if (!isTauri) {
@ -174,6 +220,17 @@ export default function Profile({ currentUser, onUpdateUser }: ProfileProps) {
setErrorMsg(null); setErrorMsg(null);
}; };
const filteredUsers = useMemo(() => {
if (!usersSearch.trim()) return localUsers;
const q = usersSearch.toLowerCase().trim();
return localUsers.filter(
(u) =>
u.email.toLowerCase().includes(q) ||
(u.username && u.username.toLowerCase().includes(q)) ||
(u.role && u.role.toLowerCase().includes(q))
);
}, [localUsers, usersSearch]);
const remainingBioChars = Math.max(0, 30 - bio.length); const remainingBioChars = Math.max(0, 30 - bio.length);
return ( return (
@ -198,14 +255,31 @@ export default function Profile({ currentUser, onUpdateUser }: ProfileProps) {
</div> </div>
</div> </div>
{/* Sub-navigation: ONLY General */} {/* Sub-navigation */}
<div className="has-profile-subtabs-row"> <div className="has-profile-subtabs-row">
<button <button
type="button" type="button"
className="has-profile-subtab active" className={`has-profile-subtab ${activeSubtab === "general" ? "active" : ""}`}
onClick={() => setActiveSubtab("general")}
> >
Основные Основные
</button> </button>
{isUserAdmin && (
<button
type="button"
className={`has-profile-subtab ${activeSubtab === "users" ? "active" : ""}`}
onClick={() => {
setActiveSubtab("users");
fetchUsers();
}}
>
<span>Пользователи программы</span>
{localUsers.length > 0 && (
<span className="has-profile-subtab-badge">{localUsers.length}</span>
)}
</button>
)}
</div> </div>
{/* Hidden File Input for Avatar */} {/* Hidden File Input for Avatar */}
@ -218,9 +292,11 @@ export default function Profile({ currentUser, onUpdateUser }: ProfileProps) {
/> />
{/* ======================================================== {/* ========================================================
2. MAIN CARD: YOUR PROFILE SUBTAB 1: GENERAL PROFILE SETTINGS
======================================================== */} ======================================================== */}
<div className="has-profile-card"> {activeSubtab === "general" && (
<>
<div className="has-profile-card">
{/* Card Header Row */} {/* Card Header Row */}
<div className="has-profile-card-header"> <div className="has-profile-card-header">
<div> <div>
@ -441,6 +517,159 @@ export default function Profile({ currentUser, onUpdateUser }: ProfileProps) {
</form> </form>
)} )}
</div> </div>
{/* ADMIN QUICK SECTION: Registered Users in Program */}
{isUserAdmin && (
<div className="has-profile-admin-card">
<div className="has-profile-admin-card-header">
<div className="has-profile-admin-card-header-left">
<div className="has-profile-admin-shield-icon">
<svg width={22} height={22} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
</div>
<div>
<h3 className="has-profile-admin-card-title">Пользователи вашей программы</h3>
<p className="has-profile-admin-card-subtitle">
В системе зарегистрировано <strong>{localUsers.length}</strong> пользователей
</p>
</div>
</div>
<button
type="button"
className="has-profile-admin-open-btn"
onClick={() => {
if (onNavigateToUsers) {
onNavigateToUsers();
} else {
setActiveSubtab("users");
}
}}
>
Все пользователи ({localUsers.length}) →
</button>
</div>
<div className="has-profile-admin-users-list">
{localUsers.slice(0, 6).map((user) => (
<div key={user.id} className="has-profile-admin-user-row">
<div className="has-profile-admin-user-avatar">
{(user.username || user.email).slice(0, 2).toUpperCase()}
</div>
<div className="has-profile-admin-user-details">
<span className="has-profile-admin-user-name">
{user.username || user.email.split("@")[0]}
</span>
<span className="has-profile-admin-user-email">{user.email}</span>
</div>
<div className="has-profile-admin-user-right">
<span className={`has-profile-status-dot ${user.is_online ? "online" : "offline"}`} />
<span className="has-profile-status-text">
{user.is_online ? "В сети" : user.last_seen_at ? `Был: ${user.last_seen_at}` : "Офлайн"}
</span>
<span className={`has-profile-admin-badge ${user.role === "Администратор" ? "admin" : "user"}`}>
{user.role}
</span>
</div>
</div>
))}
</div>
</div>
)}
</>
)}
{/* ========================================================
SUBTAB 2: REGISTERED USERS MANAGEMENT FOR ADMIN
======================================================== */}
{activeSubtab === "users" && isUserAdmin && (
<div className="has-profile-users-page-card">
<div className="has-profile-users-page-header">
<div>
<h2 className="has-profile-card-title">Список пользователей программы</h2>
<p className="has-profile-card-subtitle">
Учётные записи, зарегистрированные в базе данных сервера (всего: {localUsers.length})
</p>
</div>
<div className="has-profile-users-page-actions">
<input
type="text"
placeholder="Поиск по имени или email..."
value={usersSearch}
onChange={(e) => setUsersSearch(e.target.value)}
className="has-profile-users-search-input"
/>
<button
type="button"
className="has-profile-users-refresh-btn"
onClick={fetchUsers}
title="Обновить список"
>
↻
</button>
{onNavigateToUsers && (
<button
type="button"
className="has-profile-admin-manage-link-btn"
onClick={onNavigateToUsers}
>
Управление и права →
</button>
)}
</div>
</div>
<div className="has-profile-users-table-wrap">
<table className="has-profile-users-table">
<thead>
<tr>
<th>Пользователь</th>
<th>Email</th>
<th>Роль</th>
<th>Статус</th>
<th>Регистрация</th>
</tr>
</thead>
<tbody>
{filteredUsers.map((u) => (
<tr key={u.id}>
<td>
<div className="has-profile-table-user-cell">
<div className="has-profile-table-avatar">
{(u.username || u.email).slice(0, 2).toUpperCase()}
</div>
<span className="has-profile-table-username">
{u.username || "Без имени"}
</span>
</div>
</td>
<td className="has-profile-table-email">{u.email}</td>
<td>
<span className={`has-profile-table-role ${u.role === "Администратор" ? "admin" : "user"}`}>
{u.role}
</span>
</td>
<td>
<span className="has-profile-table-status">
<span className={`has-profile-status-dot ${u.is_online ? "online" : "offline"}`} />
{u.is_online ? "В сети" : u.last_seen_at ? `Был: ${u.last_seen_at}` : "Офлайн"}
</span>
</td>
<td className="has-profile-table-date">{u.created_at}</td>
</tr>
))}
{filteredUsers.length === 0 && (
<tr>
<td colSpan={5} className="has-profile-table-empty">
Пользователи не найдены
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)}
</div> </div>
); );
} }