user/role management

This commit is contained in:
Hamid Reza Mohammadi 2026-08-21 08:41:31 +03:30
parent 8295a03fef
commit 7415b343c1
6 changed files with 809 additions and 1 deletions

View File

@ -0,0 +1,183 @@
@page
@model GanjooRazor.Areas.Admin.Pages.RolesModel
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@{
Layout = "_AdminLayout";
ViewData["Title"] = "مدیریت نقش‌ها";
}
<div class="up-page-header">
<h1>@ViewData["Title"]</h1>
</div>
@if (!string.IsNullOrEmpty(Model.LastError))
{
<div class="up-alert up-alert--danger">@Model.LastError</div>
}
<div class="up-card">
<h2 class="up-card__title">نقش جدید</h2>
<div class="up-form-actions">
<input type="text" id="new-role-name" placeholder="نام نقش" style="flex:1;" />
<input type="text" id="new-role-description" placeholder="توضیحات" style="flex:1;" />
<a role="button" onclick="createRole()" class="up-btn up-btn--success">➕ ایجاد</a>
</div>
</div>
<script>
function createRole() {
var name = document.getElementById('new-role-name').value.trim();
var description = document.getElementById('new-role-description').value.trim();
if (name === '') {
upToast('نام نقش خالی است.', 'error');
return;
}
$.ajax({
type: "POST",
url: '?handler=NewRole',
data: { name: name, description: description },
error: function (e) {
upToast(e.responseText || String(e), 'error');
},
success: function () {
location.reload(true);
}
});
}
async function deleteRole(roleName) {
var ok = await upConfirm('آیا از حذف نقش «' + roleName + '» اطمینان دارید؟');
if (!ok) return;
$.ajax({
type: "DELETE",
url: '?handler=Role',
data: { roleName: roleName },
error: function (e) {
upToast(e.responseText || String(e), 'error');
},
success: function () {
var row = document.getElementById('tr-role-' + roleName);
row.parentNode.removeChild(row);
}
});
}
function toggleRolePermissions(roleName) {
var row = document.getElementById('perm-row-' + roleName);
if (row.style.display === 'none' || row.style.display === '') {
row.style.display = 'table-row';
loadRolePermissions(roleName);
} else {
row.style.display = 'none';
}
}
function loadRolePermissions(roleName) {
var container = document.getElementById('perm-container-' + roleName);
container.innerHTML = 'در حال بارگذاری...';
$.ajax({
type: "GET",
url: '?handler=Permissions&roleName=' + encodeURIComponent(roleName),
error: function (e) {
upToast(e.responseText || String(e), 'error');
},
success: function (items) {
renderPermissionTree(roleName, items);
}
});
}
function renderPermissionTree(roleName, items) {
var container = document.getElementById('perm-container-' + roleName);
container.innerHTML = '';
var tree = document.createElement('div');
tree.className = 'up-perm-tree';
items.forEach(function (item, itemIndex) {
var itemRow = document.createElement('div');
itemRow.className = 'up-perm-tree__item';
var itemLabel = document.createElement('strong');
itemLabel.textContent = item.description;
itemRow.appendChild(itemLabel);
tree.appendChild(itemRow);
var opsWrap = document.createElement('div');
opsWrap.className = 'up-perm-tree__ops';
item.operations.forEach(function (op, opIndex) {
var opLabel = document.createElement('label');
opLabel.className = 'up-check-row';
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = op.status;
cb.dataset.itemIndex = itemIndex;
cb.dataset.opIndex = opIndex;
opLabel.appendChild(cb);
opLabel.appendChild(document.createTextNode(op.description));
opsWrap.appendChild(opLabel);
});
tree.appendChild(opsWrap);
});
container.appendChild(tree);
var saveBtn = document.createElement('a');
saveBtn.setAttribute('role', 'button');
saveBtn.className = 'up-btn up-btn--success';
saveBtn.textContent = '💾 ذخیرهٔ دسترسی‌ها';
saveBtn.onclick = function () { saveRolePermissions(roleName, items, tree); };
container.appendChild(saveBtn);
}
function saveRolePermissions(roleName, items, tree) {
tree.querySelectorAll('input[type=checkbox]').forEach(function (cb) {
items[cb.dataset.itemIndex].operations[cb.dataset.opIndex].status = cb.checked;
});
$.ajax({
type: "PUT",
url: '?handler=Permissions&roleName=' + encodeURIComponent(roleName),
contentType: "application/json",
data: JSON.stringify(items),
error: function (e) {
upToast(e.responseText || String(e), 'error');
},
success: function () {
upToast('دسترسی‌های نقش ذخیره شد.');
}
});
}
</script>
@if (Model.Roles != null && Model.Roles.Length > 0)
{
<table>
<tr>
<th>نام</th>
<th>توضیحات</th>
<th>#</th>
</tr>
@foreach (var role in Model.Roles)
{
<tr id="tr-role-@role.Name">
<td>@role.Name</td>
<td>@role.Description</td>
<td>
<a role="button" onclick="toggleRolePermissions('@role.Name')" class="up-icon-btn" title="دسترسی‌ها" aria-label="مدیریت دسترسی‌های این نقش">🔑</a>
<a role="button" onclick="deleteRole('@role.Name')" class="up-icon-btn" title="حذف" aria-label="حذف این نقش">🗑</a>
</td>
</tr>
<tr id="perm-row-@role.Name" style="display:none">
<td colspan="3">
<div id="perm-container-@role.Name"></div>
</td>
</tr>
}
</table>
}
else
{
<partial name="_EmptyState" model="@("نقشی تعریف نشده است.")" />
}

View File

@ -0,0 +1,132 @@
using GanjooRazor.Utils;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Newtonsoft.Json;
using RSecurityBackend.Models.Auth.Db;
using RSecurityBackend.Models.Auth.Memory;
using System.IO;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
namespace GanjooRazor.Areas.Admin.Pages
{
[IgnoreAntiforgeryToken(Order = 1001)]
public class RolesModel : PageModel
{
/// <summary>
/// Last Error
/// </summary>
public string LastError { get; set; }
public RAppRole[] Roles { get; set; }
public async Task<IActionResult> OnGetAsync()
{
if (string.IsNullOrEmpty(Request.Cookies["Token"]))
return Redirect("/");
using (HttpClient secureClient = new HttpClient(new GanjoorReloginHandler(Request, Response)))
{
if (await GanjoorSessionChecker.PrepareClient(secureClient, Request, Response))
{
var response = await secureClient.GetAsync($"{APIRoot.Url}/api/roles");
if (response.IsSuccessStatusCode)
{
Roles = JsonConvert.DeserializeObject<RAppRole[]>(await response.Content.ReadAsStringAsync());
}
else
{
LastError = await response.Content.ReadAsStringAsync();
}
}
else
{
LastError = "لطفاً از گنجور خارج و مجددا به آن وارد شوید.";
}
}
return Page();
}
/// <summary>
/// create a new role - ?handler=NewRole
/// </summary>
public async Task<IActionResult> OnPostNewRoleAsync(string name, string description)
{
using (HttpClient secureClient = new HttpClient(new GanjoorReloginHandler(Request, Response)))
{
if (!await GanjoorSessionChecker.PrepareClient(secureClient, Request, Response))
return BadRequest("لطفاً از گنجور خارج و مجددا به آن وارد شوید.");
var newRole = new { name = name, description = description };
var response = await secureClient.PostAsync($"{APIRoot.Url}/api/roles", new StringContent(JsonConvert.SerializeObject(newRole), Encoding.UTF8, "application/json"));
if (!response.IsSuccessStatusCode)
return BadRequest(await response.Content.ReadAsStringAsync());
return new JsonResult(true);
}
}
/// <summary>
/// delete a role - ?handler=Role (DELETE)
/// </summary>
public async Task<IActionResult> OnDeleteRoleAsync(string roleName)
{
using (HttpClient secureClient = new HttpClient(new GanjoorReloginHandler(Request, Response)))
{
if (!await GanjoorSessionChecker.PrepareClient(secureClient, Request, Response))
return BadRequest("لطفاً از گنجور خارج و مجددا به آن وارد شوید.");
var response = await secureClient.DeleteAsync($"{APIRoot.Url}/api/roles/{roleName}");
if (!response.IsSuccessStatusCode)
return BadRequest(await response.Content.ReadAsStringAsync());
return new JsonResult(true);
}
}
/// <summary>
/// securable items tree annotated with this role's current permission status - ?handler=Permissions&roleName=...
/// </summary>
public async Task<IActionResult> OnGetPermissionsAsync(string roleName)
{
using (HttpClient secureClient = new HttpClient(new GanjoorReloginHandler(Request, Response)))
{
if (!await GanjoorSessionChecker.PrepareClient(secureClient, Request, Response))
return BadRequest("لطفاً از گنجور خارج و مجددا به آن وارد شوید.");
var response = await secureClient.GetAsync($"{APIRoot.Url}/api/roles/permissions/{roleName}");
if (!response.IsSuccessStatusCode)
return BadRequest(await response.Content.ReadAsStringAsync());
var items = JsonConvert.DeserializeObject<SecurableItem[]>(await response.Content.ReadAsStringAsync());
return new JsonResult(items);
}
}
/// <summary>
/// save this role's permissions - ?handler=Permissions&roleName=... (PUT)
/// </summary>
public async Task<IActionResult> OnPutPermissionsAsync(string roleName)
{
using (HttpClient secureClient = new HttpClient(new GanjoorReloginHandler(Request, Response)))
{
if (!await GanjoorSessionChecker.PrepareClient(secureClient, Request, Response))
return BadRequest("لطفاً از گنجور خارج و مجددا به آن وارد شوید.");
string body;
using (var reader = new StreamReader(Request.Body))
{
body = await reader.ReadToEndAsync();
}
var response = await secureClient.PutAsync($"{APIRoot.Url}/api/roles/permissions/{roleName}", new StringContent(body, Encoding.UTF8, "application/json"));
if (!response.IsSuccessStatusCode)
return BadRequest(await response.Content.ReadAsStringAsync());
return new JsonResult(true);
}
}
}
}

View File

@ -0,0 +1,216 @@
@page
@model GanjooRazor.Areas.Admin.Pages.UsersModel
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@{
Layout = "_AdminLayout";
ViewData["Title"] = "مدیریت کاربران";
ViewData["WideShell"] = true;
}
<div class="up-page-header">
<h1>@ViewData["Title"]</h1>
</div>
@if (!string.IsNullOrEmpty(Model.LastError))
{
<div class="up-alert up-alert--danger">@Model.LastError</div>
}
<div class="up-card">
<form method="get" class="up-form-actions">
<input type="text" name="filterByEmail" value="@Model.FilterByEmail" placeholder="جستجوی ایمیل" style="flex:1;" />
<input type="hidden" name="pageSize" value="@Model.PageSize" />
<input type="submit" value="🔎 جستجو" class="up-btn up-btn--ghost" />
</form>
</div>
<script>
var allRolesCache = null;
function toggleUserRow(userId) {
var row = document.getElementById('roles-row-' + userId);
if (row.style.display === 'none' || row.style.display === '') {
row.style.display = 'table-row';
loadUserRoles(userId);
} else {
row.style.display = 'none';
}
}
function loadUserRoles(userId) {
var container = document.getElementById('roles-container-' + userId);
container.innerHTML = 'در حال بارگذاری...';
$.ajax({
type: "GET",
url: '?handler=Roles&id=' + userId,
error: function (e) {
upToast(e.responseText || String(e), 'error');
},
success: function (roles) {
renderUserRoles(userId, roles);
}
});
}
function renderUserRoles(userId, roles) {
var container = document.getElementById('roles-container-' + userId);
container.innerHTML = '';
roles.forEach(function (role) {
var chip = document.createElement('span');
chip.className = 'up-role-chip';
chip.textContent = role;
var removeBtn = document.createElement('a');
removeBtn.setAttribute('role', 'button');
removeBtn.className = 'up-role-chip__remove';
removeBtn.title = 'حذف نقش';
removeBtn.textContent = '✕';
removeBtn.onclick = function () { removeUserRole(userId, role); };
chip.appendChild(removeBtn);
container.appendChild(chip);
});
var addBtn = document.createElement('a');
addBtn.setAttribute('role', 'button');
addBtn.className = 'up-btn up-btn--sm up-btn--ghost';
addBtn.textContent = '➕ نقش جدید';
addBtn.onclick = function () { showAddRole(userId, roles); };
container.appendChild(addBtn);
}
function showAddRole(userId, currentRoles) {
function withAllRoles(all) {
var unassigned = all.filter(function (r) { return currentRoles.indexOf(r) === -1; });
if (unassigned.length === 0) {
upToast('این کاربر همهٔ نقش‌های موجود را دارد.', 'error');
return;
}
var select = document.createElement('select');
unassigned.forEach(function (r) {
var opt = document.createElement('option');
opt.value = r;
opt.text = r;
select.appendChild(opt);
});
var confirmBtn = document.createElement('a');
confirmBtn.setAttribute('role', 'button');
confirmBtn.className = 'up-btn up-btn--sm up-btn--success';
confirmBtn.textContent = 'تأیید';
confirmBtn.onclick = function () { addUserRole(userId, select.value); };
var container = document.getElementById('roles-container-' + userId);
var wrap = document.createElement('span');
wrap.appendChild(select);
wrap.appendChild(confirmBtn);
container.appendChild(wrap);
}
if (allRolesCache != null) {
withAllRoles(allRolesCache);
return;
}
$.ajax({
type: "GET",
url: '?handler=AllRoles',
error: function (e) {
upToast(e.responseText || String(e), 'error');
},
success: function (all) {
allRolesCache = all;
withAllRoles(all);
}
});
}
function addUserRole(userId, role) {
$.ajax({
type: "POST",
url: '?handler=AddRole',
data: { id: userId, role: role },
error: function (e) {
upToast(e.responseText || String(e), 'error');
},
success: function () {
loadUserRoles(userId);
}
});
}
function removeUserRole(userId, role) {
$.ajax({
type: "DELETE",
url: '?handler=Role',
data: { id: userId, role: role },
error: function (e) {
upToast(e.responseText || String(e), 'error');
},
success: function () {
loadUserRoles(userId);
}
});
}
function toggleUserStatus(userId, checkbox) {
var active = checkbox.checked;
checkbox.disabled = true;
$.ajax({
type: "POST",
url: '?handler=ToggleStatus',
data: { id: userId, active: active },
error: function (e) {
checkbox.checked = !active;
checkbox.disabled = false;
upToast(e.responseText || String(e), 'error');
},
success: function () {
checkbox.disabled = false;
upToast('وضعیت کاربر به‌روز شد.');
}
});
}
</script>
@if (Model.Users != null && Model.Users.Length > 0)
{
<table>
<tr>
<th>پست الکترونیکی</th>
<th>نام</th>
<th>نام خانوادگی</th>
<th>فعال</th>
<th>#</th>
</tr>
@foreach (var user in Model.Users)
{
<tr id="tr-@user.Id">
<td>@user.Email</td>
<td>@user.FirstName</td>
<td>@user.SurName</td>
<td>
@if (user.Status == RSecurityBackend.Models.Auth.Db.RAppUserStatus.Active)
{
<input type="checkbox" checked onchange="toggleUserStatus('@user.Id', this)" />
}
else
{
<input type="checkbox" onchange="toggleUserStatus('@user.Id', this)" />
}
</td>
<td>
<a role="button" onclick="toggleUserRow('@user.Id')" class="up-icon-btn" title="نقش‌ها" aria-label="مدیریت نقش‌های این کاربر">🎭</a>
<a role="button" href="/Admin/ExamineUser?id=@user.Id" class="up-icon-btn" title="مشاهده" aria-label="مشاهدهٔ این کاربر">👁️</a>
</td>
</tr>
<tr id="roles-row-@user.Id" style="display:none">
<td colspan="5">
<div id="roles-container-@user.Id" class="up-role-chip-row"></div>
</td>
</tr>
}
</table>
}
else
{
<partial name="_EmptyState" model="@("کاربری یافت نشد.")" />
}
<partial name="_Pagination" model="Model.PaginationLinks" />

View File

@ -0,0 +1,216 @@
using GanjooRazor.Utils;
using GSpotifyProxy.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Newtonsoft.Json;
using RSecurityBackend.Models.Auth.Db;
using RSecurityBackend.Models.Auth.ViewModels;
using RSecurityBackend.Models.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
namespace GanjooRazor.Areas.Admin.Pages
{
[IgnoreAntiforgeryToken(Order = 1001)]
public class UsersModel : PageModel
{
/// <summary>
/// Last Error
/// </summary>
public string LastError { get; set; }
public PublicRAppUser[] Users { get; set; }
public PaginationMetadata Paging { get; set; }
public List<NameIdUrlImage> PaginationLinks { get; set; } = new List<NameIdUrlImage>();
public int PageNumber { get; set; } = 1;
public int PageSize { get; set; } = 15;
public string FilterByEmail { get; set; }
public async Task<IActionResult> OnGetAsync(int pageNumber = 1, int pageSize = 15, string filterByEmail = null)
{
if (string.IsNullOrEmpty(Request.Cookies["Token"]))
return Redirect("/");
PageNumber = pageNumber < 1 ? 1 : pageNumber;
PageSize = pageSize;
FilterByEmail = filterByEmail;
using (HttpClient secureClient = new HttpClient(new GanjoorReloginHandler(Request, Response)))
{
if (await GanjoorSessionChecker.PrepareClient(secureClient, Request, Response))
{
var url = $"{APIRoot.Url}/api/users?PageNumber={PageNumber}&PageSize={PageSize}";
if (!string.IsNullOrEmpty(FilterByEmail))
url += $"&filterByEmail={Uri.EscapeDataString(FilterByEmail)}";
var response = await secureClient.GetAsync(url);
if (response.IsSuccessStatusCode)
{
Users = JsonConvert.DeserializeObject<PublicRAppUser[]>(await response.Content.ReadAsStringAsync());
if (response.Headers.TryGetValues("paging-headers", out var values))
{
Paging = JsonConvert.DeserializeObject<PaginationMetadata>(values.FirstOrDefault());
if (Paging != null)
{
for (int p = 1; p <= Paging.totalPages; p++)
{
var pageUrl = $"?pageNumber={p}&pageSize={PageSize}";
if (!string.IsNullOrEmpty(FilterByEmail))
pageUrl += $"&filterByEmail={Uri.EscapeDataString(FilterByEmail)}";
PaginationLinks.Add(new NameIdUrlImage()
{
Name = p.ToString(),
Url = p == PageNumber ? "" : pageUrl
});
}
}
}
}
else
{
LastError = await response.Content.ReadAsStringAsync();
}
}
else
{
LastError = "لطفاً از گنجور خارج و مجددا به آن وارد شوید.";
}
}
return Page();
}
/// <summary>
/// role names for a specific user - ?handler=Roles&id=...
/// </summary>
public async Task<IActionResult> OnGetRolesAsync(Guid id)
{
using (HttpClient secureClient = new HttpClient(new GanjoorReloginHandler(Request, Response)))
{
if (!await GanjoorSessionChecker.PrepareClient(secureClient, Request, Response))
return BadRequest("لطفاً از گنجور خارج و مجددا به آن وارد شوید.");
var response = await secureClient.GetAsync($"{APIRoot.Url}/api/users/{id}/roles");
if (!response.IsSuccessStatusCode)
return BadRequest(await response.Content.ReadAsStringAsync());
var roles = JsonConvert.DeserializeObject<string[]>(await response.Content.ReadAsStringAsync());
return new JsonResult(roles);
}
}
/// <summary>
/// all defined role names, for populating the "add role" picker - ?handler=AllRoles
/// </summary>
public async Task<IActionResult> OnGetAllRolesAsync()
{
using (HttpClient secureClient = new HttpClient(new GanjoorReloginHandler(Request, Response)))
{
if (!await GanjoorSessionChecker.PrepareClient(secureClient, Request, Response))
return BadRequest("لطفاً از گنجور خارج و مجددا به آن وارد شوید.");
var response = await secureClient.GetAsync($"{APIRoot.Url}/api/roles");
if (!response.IsSuccessStatusCode)
return BadRequest(await response.Content.ReadAsStringAsync());
var roles = JsonConvert.DeserializeObject<RAppRole[]>(await response.Content.ReadAsStringAsync());
return new JsonResult(roles.Select(r => r.Name).ToArray());
}
}
/// <summary>
/// add a role to a user - ?handler=AddRole
/// </summary>
public async Task<IActionResult> OnPostAddRoleAsync(Guid id, string role)
{
using (HttpClient secureClient = new HttpClient(new GanjoorReloginHandler(Request, Response)))
{
if (!await GanjoorSessionChecker.PrepareClient(secureClient, Request, Response))
return BadRequest("لطفاً از گنجور خارج و مجددا به آن وارد شوید.");
var response = await secureClient.PostAsync($"{APIRoot.Url}/api/users/{id}/roles/{Uri.EscapeDataString(role)}", null);
if (!response.IsSuccessStatusCode)
return BadRequest(await response.Content.ReadAsStringAsync());
return new JsonResult(true);
}
}
/// <summary>
/// remove a role from a user - ?handler=Role (DELETE)
/// </summary>
public async Task<IActionResult> OnDeleteRoleAsync(Guid id, string role)
{
using (HttpClient secureClient = new HttpClient(new GanjoorReloginHandler(Request, Response)))
{
if (!await GanjoorSessionChecker.PrepareClient(secureClient, Request, Response))
return BadRequest("لطفاً از گنجور خارج و مجددا به آن وارد شوید.");
var response = await secureClient.DeleteAsync($"{APIRoot.Url}/api/users/{id}/roles/{Uri.EscapeDataString(role)}");
if (!response.IsSuccessStatusCode)
return BadRequest(await response.Content.ReadAsStringAsync());
return new JsonResult(true);
}
}
/// <summary>
/// toggle a user's active/inactive status - ?handler=ToggleStatus
/// </summary>
public async Task<IActionResult> OnPostToggleStatusAsync(Guid id, bool active)
{
using (HttpClient secureClient = new HttpClient(new GanjoorReloginHandler(Request, Response)))
{
if (!await GanjoorSessionChecker.PrepareClient(secureClient, Request, Response))
return BadRequest("لطفاً از گنجور خارج و مجددا به آن وارد شوید.");
var userInfoResponse = await secureClient.GetAsync($"{APIRoot.Url}/api/users/{id}");
if (!userInfoResponse.IsSuccessStatusCode)
return BadRequest(await userInfoResponse.Content.ReadAsStringAsync());
var userInfo = JsonConvert.DeserializeObject<PublicRAppUser>(await userInfoResponse.Content.ReadAsStringAsync());
// ModifyUser rejects the request unless IsAdmin exactly matches the user's actual
// current admin-role membership (this endpoint cannot itself change admin status),
// so we need their real membership, not a guess.
var rolesResponse = await secureClient.GetAsync($"{APIRoot.Url}/api/users/{id}/roles");
if (!rolesResponse.IsSuccessStatusCode)
return BadRequest(await rolesResponse.Content.ReadAsStringAsync());
var currentRoles = JsonConvert.DeserializeObject<string[]>(await rolesResponse.Content.ReadAsStringAsync());
bool isCurrentlyAdmin = currentRoles.Contains("Administrator");
var updateModel = new RegisterRAppUser()
{
Password = "",
IsAdmin = isCurrentlyAdmin,
Id = userInfo.Id,
Username = userInfo.Username,
Email = userInfo.Email,
PhoneNumber = userInfo.PhoneNumber,
FirstName = userInfo.FirstName,
SurName = userInfo.SurName,
Status = active ? RAppUserStatus.Active : RAppUserStatus.Inactive,
RImageId = userInfo.RImageId,
NickName = userInfo.NickName,
Bio = userInfo.Bio,
Website = userInfo.Website
};
var response = await secureClient.PutAsync($"{APIRoot.Url}/api/users/{id}", new StringContent(JsonConvert.SerializeObject(updateModel), Encoding.UTF8, "application/json"));
if (!response.IsSuccessStatusCode)
return BadRequest(await response.Content.ReadAsStringAsync());
return new JsonResult(true);
}
}
}
}

View File

@ -498,6 +498,14 @@
</div>
</li>
<li class="nav-item dropdown">
<a class="nav-link text-dark dropdown-toggle" href="#" id="adminUsersDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">کاربران و نقش‌ها</a>
<div class="dropdown-menu" aria-labelledby="adminUsersDropdown">
<a class="dropdown-item" asp-area="Admin" asp-page="Users">کاربران</a>
<a class="dropdown-item" asp-area="Admin" asp-page="Roles">نقش‌ها</a>
</div>
</li>
<li class="nav-item">
<a class="nav-link text-dark" href="javascript:toggleColorScheme();" title="تغییر پوسته">
<span id="icon-sun"><svg height="24px" viewBox="0 -960 960 960" width="24px"><path d="M440-760v-160h80v160h-80Zm266 110-55-55 112-115 56 57-113 113Zm54 210v-80h160v80H760ZM440-40v-160h80v160h-80ZM254-652 140-763l57-56 113 113-56 54Zm508 512L651-255l54-54 114 110-57 59ZM40-440v-80h160v80H40Zm157 300-56-57 112-112 29 27 29 28-114 114Zm283-100q-100 0-170-70t-70-170q0-100 70-170t170-70q100 0 170 70t70 170q0 100-70 170t-170 70Z" /></svg></span>

View File

@ -713,7 +713,6 @@ body.up-modal-open {
}
.up-verse-number {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 26px;
@ -788,3 +787,57 @@ body.up-modal-open {
color: var(--up-link-hover, #0056b3);
text-decoration: none;
}
/* ---------- role chips (Users.cshtml) ---------- */
.up-role-chip-row {
display: flex;
align-items: center;
gap: var(--up-space-2);
flex-wrap: wrap;
padding: var(--up-space-2) 0;
}
.up-role-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
background: var(--up-surface-alt);
border: 1px solid var(--up-border);
font-size: 0.85rem;
}
.up-role-chip__remove {
cursor: pointer;
color: var(--up-text-muted);
font-size: 0.75rem;
}
.up-role-chip__remove:hover {
color: var(--up-danger, #c0392b);
}
/* ---------- permission tree (Roles.cshtml) ---------- */
.up-perm-tree {
text-align: right;
margin-bottom: var(--up-space-3);
}
.up-perm-tree__item {
margin-top: var(--up-space-3);
}
.up-perm-tree__item:first-child {
margin-top: 0;
}
.up-perm-tree__ops {
display: flex;
flex-wrap: wrap;
gap: var(--up-space-3);
margin-top: var(--up-space-1);
padding-right: var(--up-space-3);
}