Admin Transparency Page

* Add a transparency page where regular user accounts can list the roles and
  permissions that an admin user has access to. It is available by clicking on
  the "Admin" badge on that user's profile page.
* Add additional admin scopes to lock down more functionality:
  * User feedback and reports
  * Change logs
  * User notes and admin notes
* Add friendly descriptions to what all the scopes mean in practice.
* Don't show admin notification badges to admins who aren't allowed to act on
  those notifications.
* Update the admin dashboard page and documentation for admins.
This commit is contained in:
Noah Petherbridge 2024-05-08 21:03:31 -07:00
parent 31ba987d62
commit 20d04fc370
12 changed files with 491 additions and 115 deletions

View File

@ -35,18 +35,51 @@ const (
ScopeUserInsight = "admin.user.insights"
ScopeUserImpersonate = "admin.user.impersonate"
ScopeUserBan = "admin.user.ban"
ScopeUserPromote = "admin.user.promote"
ScopeUserDelete = "admin.user.delete"
ScopeUserPromote = "admin.user.promote"
// Other admin views
ScopeFeedbackAndReports = "admin.feedback"
ScopeChangeLog = "admin.changelog"
ScopeUserNotes = "admin.user.notes"
// Admins with this scope can not be blocked by users.
ScopeUnblockable = "admin.unblockable"
// Special scope to mark an admin automagically in the Inner Circle
ScopeIsInnerCircle = "admin.override.inner-circle"
// The global wildcard scope gets all available permissions.
ScopeSuperuser = "*"
)
// Friendly description for each scope.
var AdminScopeDescriptions = map[string]string{
ScopeChatModerator: "Have operator controls in the chat room (can mark cameras as explicit, or kick/ban people from chat).",
ScopeForumModerator: "Ability to moderate the forum (edit or delete posts).",
ScopePhotoModerator: "Ability to moderate photo galleries (can see all private or friends-only photos, and edit or delete them).",
ScopeCircleModerator: "Ability to remove members from the inner circle.",
ScopeCertificationApprove: "Ability to see pending certification pictures and approve or reject them.",
ScopeCertificationList: "Ability to see existing certification pictures that have already been approved or rejected.",
ScopeCertificationView: "Ability to see and double check a specific user's certification picture on demand.",
ScopeForumAdmin: "Ability to manage forums themselves (add or remove forums, edit their properties).",
ScopeAdminScopeAdmin: "Ability to manage admin permissions for other admin accounts.",
ScopeMaintenance: "Ability to activate maintenance mode functions of the website (turn features on or off, disable signups or logins, etc.)",
ScopeUserInsight: "Ability to see admin insights about a user profile (e.g. their block lists and who blocks them).",
ScopeUserImpersonate: "Ability to log in as any user account (note: this action is logged and notifies all admins when it happens. Admins must write a reason and it is used to diagnose customer support issues, help with their certification picture, or investigate a reported Direct Message conversation they had).",
ScopeUserBan: "Ability to ban or unban user accounts.",
ScopeUserDelete: "Ability to fully delete user accounts on their behalf.",
ScopeUserPromote: "Ability to add or remove the admin status flag on a user profile.",
ScopeFeedbackAndReports: "Ability to see admin reports and user feedback.",
ScopeChangeLog: "Ability to see website change logs (e.g. history of a certification photo, gallery photo settings, etc.)",
ScopeUserNotes: "Ability to see all notes written about a user, or to see all notes written by admins.",
ScopeUnblockable: "This admin can not be added to user block lists.",
ScopeIsInnerCircle: "This admin is automatically part of the inner circle.",
ScopeSuperuser: "This admin has access to ALL admin features on the website.",
}
// Number of expected scopes for unit test and validation.
const QuantityAdminScopes = 16
const QuantityAdminScopes = 20
// The specially named Superusers group.
const AdminGroupSuperusers = "Superusers"
@ -63,12 +96,20 @@ func ListAdminScopes() []string {
ScopeCertificationView,
ScopeForumAdmin,
ScopeAdminScopeAdmin,
ScopeMaintenance,
ScopeUserInsight,
ScopeUserImpersonate,
ScopeUserBan,
ScopeUserDelete,
ScopeUserPromote,
ScopeFeedbackAndReports,
ScopeChangeLog,
ScopeUserNotes,
ScopeUnblockable,
ScopeIsInnerCircle,
}
}
func AdminScopeDescription(scope string) string {
return AdminScopeDescriptions[scope]
}

View File

@ -10,7 +10,7 @@ import (
// returned by the scope list function.
func TestAdminScopesCount(t *testing.T) {
var scopes = config.ListAdminScopes()
if len(scopes) != config.QuantityAdminScopes {
if len(scopes) != config.QuantityAdminScopes || len(scopes) != len(config.AdminScopeDescriptions) {
t.Errorf(
"The list of scopes returned by ListAdminScopes doesn't match the expected count. "+
"Expected %d, got %d",

View File

@ -201,7 +201,7 @@ func MyNotes() http.HandlerFunc {
}
// Admin notes?
if adminNotes && !currentUser.IsAdmin {
if adminNotes && !currentUser.HasAdminScope(config.ScopeUserNotes) {
adminNotes = false
}

View File

@ -0,0 +1,43 @@
package admin
import (
"net/http"
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/templates"
)
// Admin transparency page that lists the scopes and permissions an admin account has for all to see.
func Transparency() http.HandlerFunc {
tmpl := templates.Must("admin/transparency.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var (
username = r.PathValue("username")
)
// Get this user.
user, err := models.FindUser(username)
if err != nil {
templates.NotFoundPage(w, r)
return
}
// Only for admin user accounts.
if !user.IsAdmin {
templates.NotFoundPage(w, r)
return
}
// Template variables.
var vars = map[string]interface{}{
"User": user,
"AdminScopes": config.ListAdminScopes(),
}
if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
})
}

View File

@ -78,6 +78,7 @@ func New() http.Handler {
mux.Handle("GET /admin/unimpersonate", middleware.LoginRequired(admin.Unimpersonate()))
mux.Handle("GET /inner-circle", middleware.LoginRequired(account.InnerCircle()))
mux.Handle("/inner-circle/invite", middleware.LoginRequired(account.InviteCircle()))
mux.Handle("GET /admin/transparency/{username}", middleware.LoginRequired(admin.Transparency()))
// Certification Required. Pages that only full (verified) members can access.
mux.Handle("GET /photo/gallery", middleware.CertRequired(photo.SiteGallery()))
@ -95,14 +96,14 @@ func New() http.Handler {
mux.Handle("GET /admin", middleware.AdminRequired("", admin.Dashboard()))
mux.Handle("/admin/scopes", middleware.AdminRequired("", admin.Scopes()))
mux.Handle("/admin/photo/certification", middleware.AdminRequired("", photo.AdminCertification()))
mux.Handle("/admin/feedback", middleware.AdminRequired("", admin.Feedback()))
mux.Handle("/admin/feedback", middleware.AdminRequired(config.ScopeFeedbackAndReports, admin.Feedback()))
mux.Handle("/admin/user-action", middleware.AdminRequired("", admin.UserActions()))
mux.Handle("/admin/maintenance", middleware.AdminRequired(config.ScopeMaintenance, admin.Maintenance()))
mux.Handle("/forum/admin", middleware.AdminRequired(config.ScopeForumAdmin, forum.Manage()))
mux.Handle("/forum/admin/edit", middleware.AdminRequired(config.ScopeForumAdmin, forum.AddEdit()))
mux.Handle("/inner-circle/remove", middleware.LoginRequired(account.RemoveCircle()))
mux.Handle("/admin/photo/mark-explicit", middleware.AdminRequired(config.ScopePhotoModerator, admin.MarkPhotoExplicit()))
mux.Handle("GET /admin/changelog", middleware.AdminRequired("", admin.ChangeLog()))
mux.Handle("GET /admin/changelog", middleware.AdminRequired(config.ScopeChangeLog, admin.ChangeLog()))
// JSON API endpoints.
mux.HandleFunc("GET /v1/version", api.Version())

View File

@ -71,6 +71,9 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
// Test if a photo should be blurred ({{BlurExplicit .Photo}})
"BlurExplicit": BlurExplicit(r),
// Get a description for an admin scope (e.g. for transparency page).
"AdminScopeDescription": config.AdminScopeDescription,
}
}

View File

@ -104,11 +104,20 @@ func MergeUserVars(r *http.Request, m map[string]interface{}) {
log.Error("MergeUserVars: couldn't CountFriendRequests for %d: %s", user.ID, err)
}
// Are we admin?
// Are we admin? Add notification counts if the current admin can respond to them.
if user.IsAdmin {
var countCertPhotos, countFeedback int64
// Any pending certification photos or feedback?
countCertPhotos = models.CountCertificationPhotosNeedingApproval()
countFeedback = models.CountUnreadFeedback()
if user.HasAdminScope(config.ScopeCertificationApprove) {
countCertPhotos = models.CountCertificationPhotosNeedingApproval()
}
// Admin feedback available?
if user.HasAdminScope(config.ScopeFeedbackAndReports) {
countFeedback = models.CountUnreadFeedback()
}
m["NavCertificationPhotos"] = countCertPhotos
m["NavAdminFeedback"] = countFeedback

View File

@ -52,7 +52,7 @@
</div>
<!-- Admin Notes filter -->
{{if .CurrentUser.IsAdmin}}
{{if .CurrentUser.HasAdminScope "admin.user.notes"}}
<div class="column">
<div class="field">
<label class="label has-text-danger" for="admin_notes"><i class="fa fa-peace mr-1"></i> Admin Notes:</label>

View File

@ -134,10 +134,13 @@
{{if .User.IsAdmin}}
<div class="pt-1">
<div class="icon-text has-text-danger">
<span class="icon">
<i class="fa fa-peace"></i>
</span>
<strong>Admin</strong>
<a href="/admin/transparency/{{.User.Username}}" class="has-text-danger">
<span class="icon">
<i class="fa fa-peace"></i>
</span>
<strong class="has-text-danger">Admin</strong>
<sup class="fa fa-info-circle ml-1 is-size-7 has-text-success"></sup>
</a>
</div>
</div>
{{end}}

View File

@ -123,7 +123,7 @@
</form>
<!-- Admin view: everyone else's notes -->
{{if .CurrentUser.IsAdmin}}
{{if .CurrentUser.HasAdminScope "admin.user.notes"}}
<div class="card mt-6">
<div class="card-header has-background-info">
<p class="card-header-title has-text-light">
@ -176,7 +176,7 @@
</div>
<!-- Admin Feedback & Notes column -->
{{if .CurrentUser.IsAdmin}}
{{if .CurrentUser.HasAdminScope "admin.feedback"}}
<div class="column">
<div class="card">
<div class="card-header has-background-danger">

View File

@ -12,105 +12,6 @@
<div class="block p-4">
<div class="columns">
<div class="column">
<div class="card">
<header class="card-header has-background-warning">
<p class="card-header-title has-text-dark-dark">
<i class="fa fa-book mr-2"></i>
Admin Guidelines <span class="tag is-success ml-3">NEW!</span>
</p>
</header>
<div class="card-content content">
<h2>Respect the privacy of our users</h2>
<ul>
<li>
We do not snoop on our users' Direct Messages unless they <strong>report</strong> a conversation
for us to check out. The only way to access DMs is to <a href="#impersonate">impersonate</a> a
user, which can't be done in secret.
</li>
<li>
We treat the Certification Photos as sensitive information. Go there only when a certification
photo is pending approval (red notification badges will guide the way). Do not download or leak
these images; be respectful.
</li>
</ul>
<h2>What we moderate</h2>
Admin users are only expected to help moderate the following areas of the site:
<h3>1. User profile photos</h3>
<p>
Every picture uploaded to a user's profile page can be seen by admin users. The
<a href="/photo/gallery?admin_view=true">admin gallery view</a> can find <strong>all</strong>
user photos, whether private or friends-only, whether opted-in for the Site Gallery or not.
</p>
<p>
<strong>Be careful</strong> not to "Like" or comment on a picture if the user marked it
"Friends only" or "Private" and they wouldn't expect you to have been able to see it. "Like"
and "Comment" buttons are hidden in the admin gallery view to reduce accidents but they are
functional on the user's own gallery page.
</p>
<h3>2. The Forums</h3>
<p>
Keep up with the <a href="/forum/newest">newest</a> forum posts and generally make sure
people aren't fighting or uploading inappropriate photos to one of the few photo boards.
</p>
<h3>3. Reported DMs only</h3>
<p>
If a user reports a Direct Message conversation
they're having, a link to view that chat thread will be available from the report.
This will <a href="#impersonate">impersonate</a> the reporter and will be logged
- see "Impersonating users," below.
</p>
<p>
DMs are text-based only, so users won't be sending any image attachments that need
moderating and their privacy is to be respected. A user may report a problematic
conversation for us to take a look at.
</p>
<hr>
<h2 id="impersonate">Impersonating users</h2>
<p>
From a user's profile page you can "impersonate," or log in as them. You should almost
never need to do this, and only to diagnose a reported issue from their account or
something like that.
</p>
<p>
You will need to write a <strong>reason</strong> for impersonating a user. The event is
logged and will be e-mailed to the admin team along with your reason. The admin team is
alerted any time an Impersonate action is performed.
</p>
<p>
Note: when you impersonate, their "Last logged in at" time is not updated by your actions.
So if a user were last seen a month ago, they will still appear last seen a month ago.
But other interactions you make as their account (e.g. marking notifications read, reading
their unread DMs) will work and may be noticed.
</p>
<hr>
<p>
</p>
</div>
</div>
</div>
<div class="column">
<div class="card block">
<header class="card-header has-background-link">
@ -166,6 +67,214 @@
</div>
</div>
</div>
<div class="column">
<div class="card">
<header class="card-header has-background-warning">
<p class="card-header-title has-text-dark-dark">
<i class="fa fa-book mr-2"></i>
Admin Guidelines
</p>
</header>
<div class="card-content content">
<p>
<strong>Table of contents:</strong>
</p>
<ul>
<li><a href="#role">What is my role as an admin?</a></li>
<li><a href="#privacy">Respect the privacy of our users</a></li>
<li><a href="#moderate">What we moderate</a></li>
<li><a href="#impersonate">Impersonating users</a></li>
<li><a href="#chat">Chat room commands</a></li>
</ul>
<h2 id="role">What is my role as an admin?</h2>
<p>
Please see <a href="/admin/transparency/{{.CurrentUser.Username}}">your Admin Transparency page</a> to
review the list of permissions and roles you are capable of. Then, review the relevant sections
below for some guidelines and information relating to that role.
</p>
<h2 id="privacy">Respect the privacy of our users</h2>
<ul>
<li>
We do not snoop on our users' Direct Messages unless they <strong>report</strong> a conversation
for us to check out. The only way to access DMs is to <a href="#impersonate">impersonate</a> a
user, which can't be done in secret.
</li>
<li>
We treat the Certification Photos as sensitive information. Go there only when a certification
photo is pending approval (green notification badges will guide the way). Do not download or leak
these images; be respectful.
</li>
</ul>
<h2 id="moderate">What we moderate</h2>
Admin users are only expected to help moderate the following areas of the site:
<h3>1. User photo galleries</h3>
<p>
Every picture uploaded to a user's profile page can be seen by (some) admin users. The
<a href="/photo/gallery?admin_view=true">admin gallery view</a> can find <strong>all</strong>
user photos, whether private or friends-only, whether opted-in for the Site Gallery or not.
</p>
<p>
<strong>Notice:</strong> the website will not allow you to accidentally "like" a photo
that you should not have been able to see. "Like" buttons are hidden during the admin view
of the Site Gallery, but in case you click it on their profile page, the website will display
an error message "You aren't allowed to like that photo" so the user isn't alarmed.
</p>
<h3>2. The Forums</h3>
<p>
Keep up with the <a href="/forum/newest">newest</a> forum posts and generally make sure
people aren't fighting or uploading inappropriate photos to one of the few photo boards.
</p>
<h3>3. The Chat Room</h3>
<p>
If you are moderating the chat rooms, your main responsibilities are to:
</p>
<ul>
<li>
Ensure that people mark their webcams as 'Explicit' / red if they are jerking off
or being sexual. Use the <a href="#chat">/nsfw command</a> to set their camera to
red.
<ol>
<li>
The first action is to mark their camera red for them - sometimes people just
forget!
</li>
<li>
If the user fights you and removes the 'explicit' flag on their camera, you
may send them a verbal warning or kick them from the room. Please wait until
they have fought your flag at least <strong>two</strong> times: sometimes users
are confused by the wording of the "your cam was marked explicit" message and
accidentally un-mark their camera.
</li>
<li>
If users are being difficult and insisting on keeping an explicit camera blue,
kicking or banning them from the room is OK.
</li>
</ol>
</li>
<li>
Ensure that people are behaving themselves: not spamming or breaking the rules
by posting explicit photos in non-explicit channels. As above: a gentle warning is
often the first step before you kick or ban problematic people.
</li>
</ul>
<h3>4. Reported DMs only</h3>
<p>
If a user reports a Direct Message conversation
they're having, a link to view that chat thread will be available from the report.
This will <a href="#impersonate">impersonate</a> the reporter and will be logged
- see "Impersonating users," below.
</p>
<p>
DMs are text-based only, so users won't be sending any image attachments that need
moderating and their privacy is to be respected. A user may report a problematic
conversation for us to take a look at.
</p>
<hr>
<h2 id="impersonate">Impersonating users</h2>
<p>
From a user's profile page you can "impersonate," or log in as them. You should almost
never need to do this, and only to diagnose a reported issue from their account or
something like that.
</p>
<p>
You will need to write a <strong>reason</strong> for impersonating a user. The event is
logged and will be e-mailed to the admin team along with your reason. The admin team is
alerted any time an Impersonate action is performed.
</p>
<p>
Note: when you impersonate, their "Last logged in at" time is not updated by your actions.
So if a user were last seen a month ago, they will still appear last seen a month ago.
But other interactions you make as their account (e.g. marking notifications read, reading
their unread DMs) will work and may be noticed.
</p>
<hr>
<h2 id="chat">Chat room commands</h2>
<p>
If you are moderating the chat room, these are useful commands to know. You can type
these commands into the message box on the room.
</p>
<dl>
<dt><strong>/help</strong></dt>
<dd>Shows a reminder in chat about the available operator commands.</dd>
<dt><strong>/nsfw <span class="has-text-info">username</span></strong></dt>
<dd>Mark a user's webcam as 'explicit' on their behalf (turning it from a blue cam into a red cam).</dd>
<dt><strong>/kick <span class="has-text-info">username</span></strong></dt>
<dd>Kick a user from the chat room. They will be able to log back in.</dd>
<dt><strong>/ban <span class="has-text-info">username</span></strong></dt>
<dd>Temporarily ban the user from the chat room for 24 hours (by default).</dd>
<dt><strong>/ban <span class="has-text-info">username</span> <span class="has-text-warning">hours</span></strong></dt>
<dd>Provide a number of hours to set a different ban duration than the default (see examples below).</dd>
<dt><strong>/bans</strong></dt>
<dd>Get a list of currently active chat room bans.</dd>
<dt><strong>/unban <span class="has-text-info">username</span></strong></dt>
<dd>Immediately remove the ban flag on this username.</dd>
</dl>
<p>
<strong class="has-text-danger">Important:</strong>
The <code>/help</code> command lists some additional options that you generally should
never need to use. Be <strong>very</strong> careful not to issue commands like shutdown
which will reboot the chat server, as this can be very disruptive to our members.
</p>
<p>
Generally, as a volunteer chat moderator you will only be using the /nsfw, /kick and /ban
commands as needed.
</p>
<h4>Examples</h4>
<ul>
<li>/kick {{.CurrentUser.Username}}</li>
<li>/ban {{.CurrentUser.Username}}</li>
<li>/ban {{.CurrentUser.Username}} 12</li>
</ul>
<p>
Note: the @ symbol should NOT appear in front of the username, the chat server currently
won't recognize the user. If you use the @ mention auto-complete, be sure to remove the @
symbol before sending the command. This is a bug that will probably be fixed soon.
</p>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,167 @@
{{define "title"}}Admin Transparency for: {{.User.Username}}{{end}}
{{define "content"}}
<div class="container">
<section class="hero is-info is-bold">
<div class="hero-body">
<div class="container">
<h1 class="title">
<i class="fa fa-peace mr-1"></i> Admin Transparency
</h1>
<h2 class="subtitle">Scopes &amp; permissions available to: {{.User.Username}}</h2>
</div>
</div>
</section>
{{$Root := .}}
<div class="block content p-4">
<p>
This web page provides transparency for the website administrators and what their specific
responsibilities and capabilities are.
</p>
<p>
Administrators on {{PrettyTitle}} do not automatically have access to "god mode" powers to
use every admin feature across the entire website. Instead, admin accounts are assigned to
specific limited roles with related, narrowly scoped, permissions related to that role.
For example: an admin who only moderates the chat room will <strong>not</strong> have access
to see certification pictures or your private gallery pictures.
</p>
<p>
This enables {{PrettyTitle}} to recruit help from volunteer moderators to help with very
specific tasks (such as chat room or forum moderation) while keeping their permissions locked
down so they can't access other sensitive areas of the admin website.
</p>
</div>
<div class="block">
<div class="columns is-centered">
<div class="column is-half">
<div class="card mb-6" style="width: 100%; max-width: 800px">
<header class="card-header has-background-link">
<p class="card-header-title has-text-light">
<i class="fa fa-user-group mr-2"></i> Admin Permission Groups
</p>
</header>
<div class="card-content">
<div class="media block">
<div class="media-left">
{{template "avatar-64x64" .User}}
</div>
<div class="media-content">
<p class="title is-4">{{.User.NameOrUsername}}</p>
<p class="subtitle is-6">
<span class="icon"><i class="fa fa-user"></i></span>
<a href="/u/{{.User.Username}}">{{.User.Username}}</a>
<span class="tag is-danger is-light ml-2">
<i class="fa fa-peace mr-1"></i> Admin
</span>
</p>
</div>
</div>
<div class="content">
<p>
Admin accounts on {{PrettyTitle}} are assigned permissions based on the "groups"
they are in: each group relates to a specific role (such as chat moderator) and
grants the specific website permissions related to that role.
</p>
<p>
<strong>@{{.User.Username}}</strong> is a member of <strong>{{len .User.AdminGroups}} admin group{{Pluralize (len .User.AdminGroups)}}:</strong>
</p>
{{if eq (len .User.AdminGroups) 0}}
<div class="notification is-info is-light">
They are not assigned to any admin groups and so they have no special permissions aside
from the 'Admin' badge appearing on their profile page.
</div>
{{end}}
{{range .User.AdminGroups}}
<hr>
<h4 class="has-text-success">{{.Name}}</h4>
<p>
Permission scopes:
</p>
<dl>
{{range .Scopes}}
<dt>
<strong>
{{.Scope}}
{{if eq .Scope "*"}}
<small><em>(wildcard scope that grants all permissions)</em></small>
{{end}}
</strong>
</dt>
<dd>{{AdminScopeDescription .Scope}}</dd>
{{end}}
</dl>
{{end}}
</div>
</div>
</div>
<div class="card" style="width: 100%; max-width: 800px">
<header class="card-header has-background-link">
<p class="card-header-title has-text-light">
<i class="fa fa-clipboard-list mr-2"></i> All Possible Admin Permissions
</p>
</header>
<div class="card-content">
<div class="content">
<p>
For context to the above, the following is the complete and exhaustive list of
{{PrettyTitle}} admin capabilities that could be granted to an admin account.
</p>
<p>
Permissions that this admin has will be highlighted in
<strong class="has-text-success"><i class="fa fa-check mr-1"></i> green</strong>,
and permissions they <em>do not</em> have will be in
<strong class="has-text-danger"><i class="fa fa-xmark mr-1"></i> red</strong>.
</p>
<hr>
<dl>
{{range .AdminScopes}}
<dt>
{{if $Root.User.HasAdminScope .}}
<strong class="has-text-success">
<span class="icon"><i class="fa fa-check mr-1"></i></span>
<span>{{.}}</span>
</strong>
{{else}}
<strong class="has-text-danger">
<span class="icon"><i class="fa fa-xmark mr-1"></i></span>
<span>{{.}}</span>
</strong>
{{end}}
</dt>
<dd>{{AdminScopeDescription .}}</dd>
{{end}}
</dl>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{{end}}