Профиль и аккаунт

- API и страницы профиля (редактирование, смена пароля, аватар), публичные карточки.
- Сайдбар: блок пользователя, пункт Users для admin/root, исправлен порядок
  инициализации (показ admin-only после initAuthSession, currentUser).
- GET /auth/me: ответ через ProfileMeResponse, исправлена валидация (is_founder bool).

Команды и роли
- Маршруты и UI команд; при редактировании роли: видимость Team, выбор команды
  в модалке, только команды с активным членством; API team_id в details/ update.
- GET /api/v1/teams?membership=active для списка «своих» команд.
- Форма роли: сегмент Team, панель выбора команды только при Team и не при
  с
This commit is contained in:
Sergey Antropoff
2026-05-05 08:15:21 +03:00
parent 9ef7112bff
commit b2d3b6b803
45 changed files with 5299 additions and 489 deletions

View File

@@ -44,6 +44,7 @@
{% if molecule_sub is defined %}molmod--{{ molecule_sub }}{% endif %}"
data-page="{{ page }}"
data-cluster-id="{% if cluster_id is defined %}{{ cluster_id }}{% endif %}"
data-team-id="{% if team_id is defined %}{{ team_id }}{% endif %}"
data-molecule-sub="{% if molecule_sub is defined %}{{ molecule_sub }}{% endif %}"
>
<aside class="sidebar">
@@ -55,16 +56,14 @@
</span>
<div class="brand-text">
<span class="brand-name">RoleForge</span>
<span class="brand-sub">Ansible Orchestrator</span>
<span class="brand-tagline">Ansible Orchestrator</span>
</div>
</div>
</div>
<nav class="sidebar-nav">
{% include "includes/sidebar-nav.xhtml" %}
</nav>
<div class="sidebar-footer">
<button id="logout-btn" class="theme-toggle" type="button">Logout</button>
</div>
{% include "includes/sidebar-user.xhtml" %}
</div>
</aside>
<main class="content">

View File

@@ -2,14 +2,14 @@
{% from "macros/page-hero.xhtml" import page_hero %}
{% block content %}
<div class="dashboard-page">
{% call page_hero("RoleForge", "Dashboard", "Рабочая панель backend. Карточки статистики и cluster-блоки убраны — позже заменим на ваши метрики.") %}
{% call page_hero("RoleForge", "Dashboard", "Your RoleForge home: open inventory, roles, playbooks, jobs, tests, and runners from the sidebar.") %}
{% endcall %}
<section class="dashboard-panel dashboard-panel--primary">
<div class="dashboard-panel-h">
<div>
<h2 class="dashboard-panel-title">Quick Navigation</h2>
<p class="dashboard-panel-sub">Используйте меню слева для работы с ролями, inventory, плейбуками, тестами и раннерами.</p>
<p class="dashboard-panel-sub">Use the left menu to work with roles, inventories, playbooks, tests, and runners.</p>
</div>
</div>
</section>

View File

@@ -1,11 +1,11 @@
{% extends "base.xhtml" %}
{% from "macros/page-hero.xhtml" import page_hero %}
{% block content %}
{{ page_hero("HTTP " ~ status_code, (message if status_code != 500 else "Internal Server Error"), ("На сервере произошла непредвиденная ошибка. Попробуйте обновить страницу через несколько секунд." if status_code == 500 else details)) }}
{{ page_hero("HTTP " ~ status_code, (message if status_code != 500 else "Internal Server Error"), ("An unexpected error occurred on the server. Try refreshing the page in a moment." if status_code == 500 else details)) }}
<section class="error-page error-page--below-hero" aria-label="Error actions">
<div class="error-actions">
<a class="error-btn-primary" href="/">На главную</a>
<a class="error-btn-secondary" href="/tasks">Открыть задачи</a>
<a class="error-btn-primary" href="/">Home</a>
<a class="error-btn-secondary" href="/tasks">Open tasks</a>
</div>
{% if status_code != 500 and details %}
<pre class="form-output">{{ details }}</pre>

View File

@@ -6,6 +6,20 @@
<span class="menu-text">Dashboard</span>
</a>
<a href="/teams" class="menu-link" data-menu-path="/teams">
<span class="menu-icon" aria-hidden="true">
<i class="fa-solid fa-people-group"></i>
</span>
<span class="menu-text">Teams</span>
</a>
<a href="/users" class="menu-link menu-item-admin-only" data-menu-path="/users" style="display:none">
<span class="menu-icon" aria-hidden="true">
<i class="fa-solid fa-users"></i>
</span>
<span class="menu-text">Users</span>
</a>
<div class="menu-accordion" data-menu-accordion="library" id="menu-accordion-library">
<button class="menu-link menu-accordion__head" type="button" id="acc-head-library" aria-controls="acc-panel-library" aria-expanded="false">
<span class="menu-icon" aria-hidden="true"><i class="fa-solid fa-book-open"></i></span>

View File

@@ -0,0 +1,22 @@
{# Profile avatar + dropdown (AppsTemplate-style). Loaded only on authenticated dashboard pages. #}
<div class="sidebar-user-band">
<hr class="sidebar-user-hr" />
<div class="sidebar-user-menu" id="sidebar-user-menu">
<button type="button" class="sidebar-user-avatar-btn" id="sidebarUserMenuToggle" aria-expanded="false" aria-haspopup="true" aria-controls="sidebarUserMenuDropdown" title="Account menu">
<img id="sidebarUserAvatar" src="" alt="" width="44" height="44" class="sidebar-user-avatar-img" decoding="async" />
</button>
<ul class="sidebar-user-dropdown" id="sidebarUserMenuDropdown" role="menu" aria-hidden="true">
<li class="sidebar-user-dropdown__head" role="presentation">
<span class="sidebar-user-dropdown__name" id="sidebarUserDisplayName"></span>
<span class="sidebar-user-dropdown__meta muted" id="sidebarUserUsername"></span>
</li>
<li role="separator"><hr class="sidebar-user-dropdown__sep" /></li>
<li role="none"><a role="menuitem" href="/profile" class="sidebar-user-dropdown__link"><i class="fa-solid fa-user" aria-hidden="true"></i><span>My profile</span></a></li>
<li role="none"><a role="menuitem" href="/profile/edit" class="sidebar-user-dropdown__link"><i class="fa-solid fa-pen" aria-hidden="true"></i><span>Edit profile</span></a></li>
<li role="none"><a role="menuitem" href="/profile/avatar" class="sidebar-user-dropdown__link"><i class="fa-solid fa-image" aria-hidden="true"></i><span>Profile photo</span></a></li>
<li role="none"><a role="menuitem" href="/profile/password" class="sidebar-user-dropdown__link"><i class="fa-solid fa-key" aria-hidden="true"></i><span>Change password</span></a></li>
<li role="separator"><hr class="sidebar-user-dropdown__sep" /></li>
<li role="none"><button type="button" role="menuitem" class="sidebar-user-dropdown__link sidebar-user-dropdown__link--btn" id="sidebarDropdownLogoutBtn"><i class="fa-solid fa-right-from-bracket" aria-hidden="true"></i><span>Log out</span></button></li>
</ul>
</div>
</div>

View File

@@ -0,0 +1,46 @@
{% extends "base.xhtml" %}
{% from "macros/page-hero.xhtml" import page_hero %}
{% block content %}
<div class="dashboard-page profile-page" data-page="profile-avatar">
{{ page_hero("Account", "Profile photo", "Drag an image here or browse. We store your original file plus 54px, 128px, and 256px versions for the UI.") }}
<section class="dashboard-panel dashboard-panel--primary">
<div class="profile-avatar-layout">
<div class="profile-avatar-preview-col">
<p class="muted small-note" id="profile-avatar-hint">
Your current photo. After choosing an image, drag it and use zoom to frame the circle before upload.
</p>
<div class="profile-avatar-viewport-wrap">
<div id="profile-avatar-viewport" class="profile-avatar-viewport" aria-label="Avatar preview">
<img id="profile-avatar-display-img" class="profile-avatar-display-img" src="" alt="" />
<div id="profile-avatar-edit-layer" class="profile-avatar-edit-layer" hidden="hidden">
<img id="profile-avatar-edit-img" src="" alt="" draggable="false" />
</div>
</div>
</div>
<div id="profile-avatar-controls" class="profile-avatar-controls" hidden="hidden">
<label class="profile-avatar-zoom-label" for="profile-avatar-zoom">
<span>Zoom</span>
<input type="range" id="profile-avatar-zoom" min="100" max="400" value="100" step="1" />
</label>
<p class="muted small-note profile-avatar-drag-hint">Drag the photo to position it inside the circle.</p>
</div>
</div>
<div class="profile-avatar-upload-col">
<div id="profile-avatar-drop" class="profile-avatar-drop" tabindex="0" role="button" aria-label="Drop image to upload">
<input type="file" id="profile-avatar-file" accept="image/jpeg,image/png,image/gif,image/webp" class="profile-avatar-file-input" />
<div class="profile-avatar-drop-inner">
<i class="fa-solid fa-cloud-arrow-up profile-avatar-drop-icon" aria-hidden="true"></i>
<p><strong>Drag &amp; drop</strong> an image here, or <span class="profile-avatar-browse">browse</span></p>
<p class="muted small-note">JPEG, PNG, GIF, WebP · max 5 MB</p>
</div>
</div>
<div class="profile-avatar-actions">
<button type="button" class="cta-button" id="profile-avatar-upload-btn" disabled="disabled">Upload</button>
<button type="button" class="btn-danger" id="profile-avatar-remove-btn">Remove photo</button>
<button type="button" class="btn-muted" id="profile-avatar-back-btn">Back to profile</button>
</div>
</div>
</div>
</section>
</div>
{% endblock %}

View File

@@ -0,0 +1,27 @@
{% extends "base.xhtml" %}
{% from "macros/page-hero.xhtml" import page_hero %}
{% block content %}
<div class="dashboard-page profile-page" data-page="profile-edit">
{{ page_hero("Account", "Edit profile", "Update your username, display name, and short bio.") }}
<section class="dashboard-panel dashboard-panel--primary">
<form id="profile-edit-form" class="roles-form-grid profile-edit-form">
<label class="schema-field">
<span class="schema-field__label">Username</span>
<input type="text" id="profile-edit-username" required="required" autocomplete="username" />
</label>
<label class="schema-field">
<span class="schema-field__label">Full name</span>
<input type="text" id="profile-edit-fullname" required="required" autocomplete="name" />
</label>
<label class="schema-field schema-field--wide">
<span class="schema-field__label">Bio</span>
<textarea id="profile-edit-bio" rows="6" placeholder="Short introduction visible on your public profile…"></textarea>
</label>
<div class="profile-edit-actions schema-field schema-field--wide">
<button type="submit" class="cta-button">Save changes</button>
<button type="button" class="btn-muted" id="profile-edit-cancel-btn">Cancel</button>
</div>
</form>
</section>
</div>
{% endblock %}

View File

@@ -0,0 +1,27 @@
{% extends "base.xhtml" %}
{% from "macros/page-hero.xhtml" import page_hero %}
{% block content %}
<div class="dashboard-page profile-page" data-page="profile">
{% call page_hero("Account", "My profile", "Your account details and how others see you in RoleForge.") %}
<div class="dashboard-hero-actions">
<a href="/profile/edit" class="cta-button cta-button--secondary">Edit profile</a>
<a href="/profile/avatar" class="cta-button">Profile photo</a>
</div>
{% endcall %}
<section class="dashboard-panel dashboard-panel--primary">
<div class="profile-me-layout">
<div class="profile-me-avatar-wrap">
<img id="profile-me-avatar" class="profile-me-avatar" src="" alt="" width="128" height="128" />
</div>
<div class="profile-me-fields">
<dl class="profile-dl">
<dt>Username</dt><dd id="profile-me-username"></dd>
<dt>Email</dt><dd id="profile-me-email"></dd>
<dt>Full name</dt><dd id="profile-me-fullname"></dd>
<dt>Bio</dt><dd id="profile-me-bio" class="profile-me-bio"></dd>
</dl>
</div>
</div>
</section>
</div>
{% endblock %}

View File

@@ -0,0 +1,27 @@
{% extends "base.xhtml" %}
{% from "macros/page-hero.xhtml" import page_hero %}
{% block content %}
<div class="dashboard-page profile-page" data-page="profile-password">
{{ page_hero("Account", "Change password", "Enter your current password, then choose a new one (at least 8 characters).") }}
<section class="dashboard-panel dashboard-panel--primary">
<form id="profile-password-form" class="roles-form-grid profile-edit-form" autocomplete="off">
<label class="schema-field schema-field--wide">
<span class="schema-field__label">Current password</span>
<input type="password" id="profile-password-current" required="required" autocomplete="current-password" />
</label>
<label class="schema-field schema-field--wide">
<span class="schema-field__label">New password</span>
<input type="password" id="profile-password-new" required="required" minlength="8" autocomplete="new-password" />
</label>
<label class="schema-field schema-field--wide">
<span class="schema-field__label">Confirm new password</span>
<input type="password" id="profile-password-confirm" required="required" minlength="8" autocomplete="new-password" />
</label>
<div class="profile-edit-actions schema-field schema-field--wide">
<button type="submit" class="cta-button">Update password</button>
<button type="button" class="btn-muted" id="profile-password-cancel-btn">Cancel</button>
</div>
</form>
</section>
</div>
{% endblock %}

View File

@@ -0,0 +1,27 @@
{% extends "base.xhtml" %}
{% from "macros/page-hero.xhtml" import page_hero %}
{% block content %}
<div class="dashboard-page profile-page" data-page="profile-user" data-user-id="{{ user_id }}">
{{ page_hero("People", "User profile", "Public information for this account.") }}
<p id="profile-public-removed-banner" class="profile-removed-banner" style="display:none" role="status"></p>
<div id="profile-root-role-slot"></div>
<div id="profile-admin-actions" class="profile-admin-actions" style="display:none" aria-label="Admin actions"></div>
<section class="dashboard-panel dashboard-panel--primary">
<div class="profile-me-layout">
<div class="profile-me-avatar-wrap">
<img id="profile-public-avatar" class="profile-me-avatar" src="" alt="" width="128" height="128" />
</div>
<div class="profile-me-fields">
<dl class="profile-dl">
<dt>Username</dt><dd id="profile-public-username"></dd>
<dt>Full name</dt><dd id="profile-public-fullname"></dd>
<dt>Role</dt><dd id="profile-public-role"></dd>
<dt>Account group</dt><dd id="profile-public-account-group"></dd>
<dt>Founding user</dt><dd id="profile-public-founder"></dd>
<dt>Bio</dt><dd id="profile-public-bio" class="profile-me-bio"></dd>
</dl>
</div>
</div>
</section>
</div>
{% endblock %}

View File

@@ -37,11 +37,20 @@
<span class="schema-field__label">Description</span>
<textarea id="role-view-description" rows="5" placeholder="Describe what this role does…"></textarea>
</label>
<label class="schema-field schema-field--wide">
<span class="schema-field__label">Tags</span>
<input type="text" id="role-view-tags" placeholder="e.g. nginx, ssl, web — comma-separated" autocomplete="off" />
</label>
<div class="schema-field schema-field--wide" id="role-os-families-wrap">
<span class="schema-field__label">Target OS families</span>
<div class="role-os-family-grid" id="role-os-family-grid" aria-label="Target operating system families"></div>
<p class="muted small-note" style="margin:6px 0 0">Optional. Shown as badges in the role catalog; pick all that apply.</p>
</div>
<div class="schema-field schema-field--wide role-visibility-field">
<span class="schema-field__label" id="role-visibility-label">Who can see this role</span>
<div class="radio-segment-group role-visibility-segments" role="radiogroup" aria-labelledby="role-visibility-label">
<label class="radio-segment">
<input type="radio" name="role-view-visibility" value="public" checked="checked" />
<input type="radio" name="role-view-visibility" value="public" />
<span class="radio-segment-face">
<span class="radio-segment-title radio-segment-title--with-icon">
<i class="fa-solid fa-globe radio-segment-icon" aria-hidden="true"></i>
@@ -50,19 +59,18 @@
<span class="radio-segment-desc">Listed for everyone; anyone can browse or fork.</span>
</span>
</label>
<label class="radio-segment radio-segment--disabled">
<input type="radio" name="role-view-visibility" value="team" disabled="disabled" title="Coming later" />
<label class="radio-segment">
<input type="radio" name="role-view-visibility" value="team" />
<span class="radio-segment-face">
<span class="radio-segment-title radio-segment-title--with-icon">
<i class="fa-solid fa-users radio-segment-icon" aria-hidden="true"></i>
<span>Team</span>
<span class="role-vis-soon-badge">Soon</span>
</span>
<span class="radio-segment-desc">Visible to your organization (in development).</span>
<span class="radio-segment-desc">Visible to members of the team you choose.</span>
</span>
</label>
<label class="radio-segment">
<input type="radio" name="role-view-visibility" value="personal" />
<input type="radio" name="role-view-visibility" value="personal" checked="checked" />
<span class="radio-segment-face">
<span class="radio-segment-title radio-segment-title--with-icon">
<i class="fa-solid fa-user-shield radio-segment-icon" aria-hidden="true"></i>
@@ -72,7 +80,15 @@
</span>
</label>
</div>
<p class="muted small-note role-visibility-footnote">Default is Public. Personal keeps the role off the shared library.</p>
<p class="muted small-note role-visibility-footnote">New roles are personal. After editing, open the role and use Publish to add it to the public library.</p>
</div>
<div class="schema-field schema-field--wide" id="role-team-picker-wrap" hidden="hidden">
<span class="schema-field__label">Team</span>
<div class="role-team-picker-row">
<span id="role-team-display" class="role-team-display muted"></span>
<button type="button" class="btn-muted" id="role-team-choose-btn">Choose team…</button>
</div>
<p class="muted small-note" style="margin:6px 0 0">Only teams you belong to (active membership) are listed.</p>
</div>
</form>
</section>

View File

@@ -4,6 +4,8 @@
<div class="dashboard-page roles-page" data-role-id="{{ role_id }}">
{% call page_hero("Library", "Role Details", "Edit metadata and role files from the catalog; save to persist.") %}
<div class="dashboard-hero-actions dashboard-hero-actions--roles" aria-label="Role actions">
<button type="button" class="btn-danger" id="role-view-delete-btn" hidden="hidden">Delete</button>
<button type="button" class="cta-button" id="role-view-fork-btn" hidden="hidden">Fork to edit</button>
<button type="button" class="cta-button" id="role-view-hero-export-btn" aria-label="Export role as ZIP archive">
Export
</button>
@@ -43,11 +45,20 @@
<span class="schema-field__label">Description</span>
<textarea id="role-view-description" rows="5" placeholder="Describe what this role does…"></textarea>
</label>
<label class="schema-field schema-field--wide">
<span class="schema-field__label">Tags</span>
<input type="text" id="role-view-tags" placeholder="e.g. nginx, ssl, web — comma-separated" autocomplete="off" />
</label>
<div class="schema-field schema-field--wide" id="role-os-families-wrap">
<span class="schema-field__label">Target OS families</span>
<div class="role-os-family-grid" id="role-os-family-grid" aria-label="Target operating system families"></div>
<p class="muted small-note" style="margin:6px 0 0">Optional. Shown as badges in the role catalog; pick all that apply.</p>
</div>
<div class="schema-field schema-field--wide role-visibility-field">
<span class="schema-field__label" id="role-visibility-label">Who can see this role</span>
<div class="radio-segment-group role-visibility-segments" role="radiogroup" aria-labelledby="role-visibility-label">
<label class="radio-segment">
<input type="radio" name="role-view-visibility" value="public" checked="checked" />
<input type="radio" name="role-view-visibility" value="public" />
<span class="radio-segment-face">
<span class="radio-segment-title radio-segment-title--with-icon">
<i class="fa-solid fa-globe radio-segment-icon" aria-hidden="true"></i>
@@ -56,19 +67,18 @@
<span class="radio-segment-desc">Listed for everyone; anyone can browse or fork.</span>
</span>
</label>
<label class="radio-segment radio-segment--disabled">
<input type="radio" name="role-view-visibility" value="team" disabled="disabled" title="Coming later" />
<label class="radio-segment">
<input type="radio" name="role-view-visibility" value="team" />
<span class="radio-segment-face">
<span class="radio-segment-title radio-segment-title--with-icon">
<i class="fa-solid fa-users radio-segment-icon" aria-hidden="true"></i>
<span>Team</span>
<span class="role-vis-soon-badge">Soon</span>
</span>
<span class="radio-segment-desc">Visible to your organization (in development).</span>
<span class="radio-segment-desc">Visible to members of the team you choose.</span>
</span>
</label>
<label class="radio-segment">
<input type="radio" name="role-view-visibility" value="personal" />
<input type="radio" name="role-view-visibility" value="personal" checked="checked" />
<span class="radio-segment-face">
<span class="radio-segment-title radio-segment-title--with-icon">
<i class="fa-solid fa-user-shield radio-segment-icon" aria-hidden="true"></i>
@@ -78,7 +88,15 @@
</span>
</label>
</div>
<p class="muted small-note role-visibility-footnote">Editing someone elses shared role creates your own copy; visibility applies to your version.</p>
<p class="muted small-note role-visibility-footnote">Personal roles can become Public via “Who can see this role”. Public roles can only be deleted by an administrator; personal roles can be deleted by you or an administrator.</p>
</div>
<div class="schema-field schema-field--wide" id="role-team-picker-wrap" hidden="hidden">
<span class="schema-field__label">Team</span>
<div class="role-team-picker-row">
<span id="role-team-display" class="role-team-display muted"></span>
<button type="button" class="btn-muted" id="role-team-choose-btn">Choose team…</button>
</div>
<p class="muted small-note" style="margin:6px 0 0">Only teams you belong to (active membership) are listed.</p>
</div>
</form>
</section>

View File

@@ -0,0 +1,9 @@
{% extends "base.xhtml" %}
{% from "macros/page-hero.xhtml" import page_hero %}
{% block content %}
<div class="dashboard-page team-detail-page">
<div id="team-detail-root" data-team-id="{{ team_id }}">
<p class="muted" style="margin:0">Loading team…</p>
</div>
</div>
{% endblock %}

24
app/templates/teams.xhtml Normal file
View File

@@ -0,0 +1,24 @@
{% extends "base.xhtml" %}
{% from "macros/page-hero.xhtml" import page_hero %}
{% block content %}
<div class="dashboard-page teams-page">
{% call page_hero("Collaboration", "Teams", "Create a team, invite members, and share Ansible roles visible only to the team. Request to join open teams or leave when you no longer need access.") %}
<div class="dashboard-hero-actions">
<button type="button" class="cta-button" id="teams-create-open-btn">Create team</button>
</div>
{% endcall %}
<section class="dashboard-panel dashboard-panel--primary">
<div class="dashboard-panel-h">
<div>
<h2 class="dashboard-panel-title">All teams</h2>
<p class="dashboard-panel-sub">Open a team for members, pending requests, invitations, and shared roles.</p>
</div>
<button type="button" class="btn-muted" id="teams-refresh-btn">Refresh</button>
</div>
<div id="teams-output" class="audit-logs-output" aria-live="polite">
<p class="muted" style="margin:0">Loading teams…</p>
</div>
</section>
</div>
{% endblock %}

View File

@@ -1,7 +1,7 @@
{% extends "base.xhtml" %}
{% from "macros/page-hero.xhtml" import page_hero %}
{% block content %}
{{ page_hero("Admin", "User profile", "View clusters, role bindings, access, and recent audit. Actions: disable (ban), enable, or delete the account.") }}
{{ page_hero("Admin", "User profile", "View clusters, role bindings, access, and recent audit. The chief administrator may apply a timed suspension (reason recorded). Any administrator may remove a user from the directory.") }}
<div
class="dashboard-page user-detail-page"
id="user-detail-root"
@@ -13,6 +13,9 @@
<div id="user-detail-content" class="user-detail-content" style="display:none">
<section class="dashboard-panel">
<h1 class="page-header user-detail-title"><span id="user-detail-username">User #{{ user_id }}</span></h1>
<p class="user-detail-public-link-wrap" style="margin:0 0 0.75rem">
<a href="/profile/user/{{ user_id }}" class="cta-button cta-button--secondary">Open public profile</a>
</p>
<div class="form-actions form-actions-start user-admin-actions" id="user-admin-actions" aria-label="User administration"></div>
<div class="admin-user-detail__grid" id="user-detail-body"></div>
</section>

View File

@@ -7,7 +7,7 @@
<div class="dashboard-panel-h">
<div>
<h2 class="dashboard-panel-title">In Progress</h2>
<p class="dashboard-panel-sub">Эта страница подготовлена под соответствующие API-операции и будет заполнена полноценными формами на следующем шаге.</p>
<p class="dashboard-panel-sub">Placeholder for this workspace section; forms and API workflows will appear here as they are implemented.</p>
</div>
</div>
</section>