feat(settings): add user profile photo upload and tabbed layout
This commit is contained in:
@@ -139,6 +139,18 @@ module Volumen
|
||||
change_fediverse_creator
|
||||
end
|
||||
|
||||
app.post "/admin/settings/photo" do
|
||||
require_login!
|
||||
check_csrf!
|
||||
change_photo
|
||||
end
|
||||
|
||||
app.post "/admin/settings/photo/remove" do
|
||||
require_login!
|
||||
check_csrf!
|
||||
remove_photo
|
||||
end
|
||||
|
||||
app.post "/admin/settings/users" do
|
||||
require_login!
|
||||
require_admin!
|
||||
|
||||
@@ -207,6 +207,10 @@ module Volumen
|
||||
current_user_record&.fediverse_creator
|
||||
end
|
||||
|
||||
def current_user_photo
|
||||
current_user_record&.photo
|
||||
end
|
||||
|
||||
def admin?
|
||||
current_role == "admin"
|
||||
end
|
||||
@@ -325,6 +329,23 @@ module Volumen
|
||||
render_settings(notice: "Fediverse handle updated.")
|
||||
end
|
||||
|
||||
def change_photo
|
||||
upload = params["photo"]
|
||||
unless upload.is_a?(Hash) && upload[:tempfile]
|
||||
return render_settings(error: "No file selected.")
|
||||
end
|
||||
|
||||
validate_upload!(upload)
|
||||
url = store.store_user_photo(current_user, upload[:filename], upload[:tempfile])
|
||||
users.update_photo(current_user, url)
|
||||
render_settings(notice: "Profile photo updated.")
|
||||
end
|
||||
|
||||
def remove_photo
|
||||
users.update_photo(current_user, nil)
|
||||
render_settings(notice: "Profile photo removed.")
|
||||
end
|
||||
|
||||
def create_user
|
||||
name = presence(params["username"])
|
||||
password = presence(params["password"])
|
||||
|
||||
+10
-1
@@ -61,8 +61,17 @@ module Volumen
|
||||
end
|
||||
|
||||
def store_upload(original_name, source)
|
||||
FileUtils.mkdir_p(media_dir)
|
||||
name = safe_media_name(original_name)
|
||||
FileUtils.mkdir_p(media_dir)
|
||||
File.open(File.join(media_dir, name), "wb") { |file| IO.copy_stream(source, file) }
|
||||
"/media/#{name}"
|
||||
end
|
||||
|
||||
def store_user_photo(username, original_name, source)
|
||||
ext = File.extname(original_name.to_s).gsub(/[^a-zA-Z0-9.]/, "").downcase
|
||||
ext = ".webp" if ext.empty?
|
||||
name = "#{username}-#{SecureRandom.hex(4)}#{ext}"
|
||||
FileUtils.mkdir_p(media_dir)
|
||||
File.open(File.join(media_dir, name), "wb") { |file| IO.copy_stream(source, file) }
|
||||
"/media/#{name}"
|
||||
end
|
||||
|
||||
@@ -15,7 +15,7 @@ module Volumen
|
||||
ROLES = %w[admin author].freeze
|
||||
DEFAULT_ROLE = "author"
|
||||
|
||||
User = Struct.new(:username, :password_hash, :role, :name, :fediverse_creator)
|
||||
User = Struct.new(:username, :password_hash, :role, :name, :fediverse_creator, :photo)
|
||||
|
||||
def initialize(path:, bootstrap_hash: nil)
|
||||
@path = path
|
||||
@@ -33,7 +33,7 @@ module Volumen
|
||||
elsif @bootstrap_hash.empty?
|
||||
[]
|
||||
else
|
||||
[User.new("admin", @bootstrap_hash, "admin", nil, nil)]
|
||||
[User.new("admin", @bootstrap_hash, "admin", nil, nil, nil)]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -54,7 +54,7 @@ module Volumen
|
||||
def add(username, password, role = DEFAULT_ROLE)
|
||||
return nil if username.to_s.empty? || find(username)
|
||||
|
||||
user = User.new(username, Password.hash(password), normalize_role(role), nil, nil)
|
||||
user = User.new(username, Password.hash(password), normalize_role(role), nil, nil, nil)
|
||||
persist(all + [user])
|
||||
user
|
||||
end
|
||||
@@ -69,6 +69,10 @@ module Volumen
|
||||
end
|
||||
end
|
||||
|
||||
def update_photo(username, path)
|
||||
mutate(username) { |user| user.photo = path }
|
||||
end
|
||||
|
||||
def update_password(username, password)
|
||||
mutate(username) { |user| user.password_hash = Password.hash(password) }
|
||||
end
|
||||
@@ -132,7 +136,7 @@ module Volumen
|
||||
data = TomlRB.load_file(@path)
|
||||
Array(data["users"]).map do |entry|
|
||||
User.new(entry["username"], entry["password_hash"], entry["role"] || "admin",
|
||||
entry["name"], entry["fediverse_creator"])
|
||||
entry["name"], entry["fediverse_creator"], entry["photo"])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -151,6 +155,7 @@ module Volumen
|
||||
}
|
||||
hash["name"] = user.name if user.name
|
||||
hash["fediverse_creator"] = user.fediverse_creator if user.fediverse_creator
|
||||
hash["photo"] = user.photo if user.photo
|
||||
hash
|
||||
end
|
||||
end
|
||||
|
||||
@@ -102,6 +102,11 @@
|
||||
display: grid; place-items: center; font-weight: 600; font-size: 0.78rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.avatar-img {
|
||||
background: var(--surface-2);
|
||||
object-fit: cover;
|
||||
padding: 0;
|
||||
}
|
||||
.user-info { flex: 1; min-width: 0; }
|
||||
.user-info .name { font-size: 0.82rem; font-weight: 600; color: var(--fg);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
@@ -360,17 +365,114 @@
|
||||
.data-table tr:last-child td { border-bottom: none; }
|
||||
|
||||
/* --- Settings --- */
|
||||
.settings-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
.settings-nav {
|
||||
display: flex; gap: 0.4rem; margin-bottom: 1.25rem;
|
||||
border-bottom: 1px solid var(--border); padding-bottom: 0;
|
||||
display: flex; flex-direction: column; gap: 0.15rem;
|
||||
position: sticky; top: 5rem;
|
||||
padding: 0.4rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.settings-nav a {
|
||||
padding: 0.55rem 0.9rem; color: var(--fg-muted); text-decoration: none;
|
||||
font-size: 0.85rem; font-weight: 550; border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
display: flex; align-items: center; gap: 0.6rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: 0.85rem; font-weight: 550;
|
||||
color: var(--fg-muted);
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.settings-nav a:hover { background: var(--surface-2); color: var(--fg); }
|
||||
.settings-nav a.active { background: var(--accent-soft); color: var(--accent); }
|
||||
.settings-nav a svg { width: 16px; height: 16px; flex-shrink: 0; }
|
||||
.settings-panels { display: flex; flex-direction: column; gap: 1.25rem; min-width: 0; }
|
||||
.settings-panels .surface { padding: 0; overflow: hidden; }
|
||||
.settings-panels .surface-head {
|
||||
padding: 1.5rem 1.75rem 1.25rem;
|
||||
margin-bottom: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.settings-section {
|
||||
display: flex; gap: 1.25rem;
|
||||
padding: 1.5rem 1.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.settings-section:first-of-type { border-top: none; }
|
||||
.settings-section-icon {
|
||||
width: 40px; height: 40px; flex-shrink: 0;
|
||||
background: var(--accent-soft); color: var(--accent);
|
||||
border-radius: 10px;
|
||||
display: grid; place-items: center;
|
||||
}
|
||||
.settings-section-icon svg { width: 20px; height: 20px; }
|
||||
.settings-section-body { flex: 1; min-width: 0; }
|
||||
.settings-section-body h3 { font-size: 0.95rem; font-weight: 600; margin: 0 0 0.2rem; }
|
||||
.settings-section-desc { font-size: 0.82rem; color: var(--fg-muted); margin: 0 0 1rem; line-height: 1.5; }
|
||||
.settings-inline-form { display: flex; gap: 0.5rem; align-items: flex-end; flex-wrap: wrap; }
|
||||
.settings-inline-form .field { flex: 1; min-width: 220px; margin-bottom: 0; }
|
||||
.settings-inline-form .btn { margin-bottom: 1px; }
|
||||
|
||||
.settings-photo-form { margin-top: 0.5rem; }
|
||||
.photo-input-row { display: flex; gap: 1.25rem; align-items: center; flex-wrap: wrap; }
|
||||
.photo-preview {
|
||||
width: 80px; height: 80px; border-radius: 50%; object-fit: cover;
|
||||
border: 2px solid var(--border); flex-shrink: 0;
|
||||
}
|
||||
.photo-preview-placeholder {
|
||||
background: var(--accent-soft); color: var(--accent);
|
||||
display: grid; place-items: center; font-weight: 600; font-size: 1.75rem;
|
||||
}
|
||||
.photo-input-fields { flex: 1; min-width: 220px; }
|
||||
.file-input {
|
||||
font: inherit; color: var(--fg); padding: 0.5rem 0.75rem;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); width: 100%; font-size: 0.85rem;
|
||||
}
|
||||
.photo-buttons { display: flex; gap: 0.5rem; margin-top: 0.6rem; }
|
||||
.btn-danger { color: var(--danger); }
|
||||
|
||||
.users-list { padding: 0.5rem 0; }
|
||||
.user-row {
|
||||
display: flex; align-items: center; gap: 0.85rem;
|
||||
padding: 0.85rem 1.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.user-row:first-child { border-top: none; }
|
||||
.user-avatar {
|
||||
width: 36px; height: 36px; border-radius: 50%; object-fit: cover; flex-shrink: 0;
|
||||
}
|
||||
.user-avatar-placeholder {
|
||||
background: var(--accent-soft); color: var(--accent);
|
||||
display: grid; place-items: center; font-weight: 600; font-size: 0.9rem;
|
||||
}
|
||||
.user-info { flex: 1; min-width: 0; }
|
||||
.user-name { font-weight: 600; font-size: 0.9rem; color: var(--fg); }
|
||||
.user-username { font-size: 0.78rem; color: var(--fg-muted); font-family: var(--font-mono); }
|
||||
.user-role { display: flex; align-items: center; }
|
||||
.select-sm { padding: 0.3rem 0.6rem; font-size: 0.8rem; width: 110px; }
|
||||
|
||||
.add-user-form {
|
||||
padding: 1.5rem 1.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.add-user-form h3 { color: var(--fg-muted); }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.settings-layout { grid-template-columns: 1fr; }
|
||||
.settings-nav { position: static; flex-direction: row; overflow-x: auto; }
|
||||
.settings-nav a { white-space: nowrap; }
|
||||
.settings-section { flex-direction: column; gap: 0.85rem; }
|
||||
.settings-section-icon { width: 36px; height: 36px; }
|
||||
.settings-section-icon svg { width: 18px; height: 18px; }
|
||||
.photo-input-row { flex-direction: column; align-items: flex-start; }
|
||||
}
|
||||
.settings-nav a:hover { color: var(--fg); }
|
||||
.settings-nav a.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
@@ -515,7 +617,11 @@
|
||||
</div>
|
||||
</nav>
|
||||
<div class="sidebar-foot">
|
||||
<div class="avatar"><%= h((current_user_name || current_user).to_s[0].upcase) %></div>
|
||||
<% if current_user_photo %>
|
||||
<img class="avatar avatar-img" src="<%= h(current_user_photo) %>" alt="">
|
||||
<% else %>
|
||||
<div class="avatar"><%= h((current_user_name || current_user).to_s[0].upcase) %></div>
|
||||
<% end %>
|
||||
<div class="user-info">
|
||||
<div class="name"><%= h(current_user_name || current_user) %></div>
|
||||
<div class="role"><%= h(current_role) %></div>
|
||||
|
||||
+218
-138
@@ -18,147 +18,227 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="surface">
|
||||
<div class="surface-head">
|
||||
<div>
|
||||
<h2>Your account</h2>
|
||||
<p class="lede">Signed in as <strong style="color: var(--fg);"><%= h(current_user) %></strong> · <%= h(current_role) %></p>
|
||||
</div>
|
||||
<div class="settings-layout">
|
||||
<nav class="settings-nav" aria-label="Settings sections">
|
||||
<a href="#account" class="active">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
Account
|
||||
</a>
|
||||
<% if admin? %>
|
||||
<a href="#users">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
Users
|
||||
</a>
|
||||
<% end %>
|
||||
</nav>
|
||||
|
||||
<div class="settings-panels">
|
||||
<section id="account" class="surface">
|
||||
<div class="surface-head">
|
||||
<div>
|
||||
<h2>Account</h2>
|
||||
<p class="lede">Signed in as <strong style="color: var(--fg);"><%= h(current_user) %></strong> · <%= h(current_role) %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="settings-section-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/><path d="M12 11v6"/><path d="M9 14h6"/></svg>
|
||||
</div>
|
||||
<div class="settings-section-body">
|
||||
<h3>Profile photo</h3>
|
||||
<p class="settings-section-desc">WebP or AVIF, max 10 MB. Shown in the sidebar.</p>
|
||||
<form method="post" action="<%= to("/admin/settings/photo") %>" enctype="multipart/form-data" class="settings-photo-form">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||
<div class="photo-input-row">
|
||||
<% if current_user_record&.photo %>
|
||||
<img src="<%= h(current_user_record.photo) %>" alt="" class="photo-preview">
|
||||
<% else %>
|
||||
<div class="photo-preview photo-preview-placeholder"><%= h((current_user_record&.name || current_user).to_s[0].upcase) %></div>
|
||||
<% end %>
|
||||
<div class="photo-input-fields">
|
||||
<input type="file" name="photo" accept="image/webp,image/avif" required class="file-input">
|
||||
<div class="photo-buttons">
|
||||
<button type="submit" class="btn btn-primary">Upload</button>
|
||||
<% if current_user_record&.photo %>
|
||||
<button type="submit" form="remove-photo-form" class="btn btn-ghost btn-danger">Remove</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<% if current_user_record&.photo %>
|
||||
<form id="remove-photo-form" method="post" action="<%= to("/admin/settings/photo/remove") %>" style="display: none;">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||
</form>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="settings-section-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>
|
||||
</div>
|
||||
<div class="settings-section-body">
|
||||
<h3>Identity</h3>
|
||||
<p class="settings-section-desc">Display name pre-fills the author field. Fediverse handle pre-fills the creator.</p>
|
||||
<form method="post" action="<%= to("/admin/settings/name") %>" class="settings-inline-form">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||
<div class="field">
|
||||
<label for="name">Display name (real name)</label>
|
||||
<input id="name" class="input" type="text" name="name" value="<%= h(current_user_record&.name) %>" placeholder="Your real name">
|
||||
</div>
|
||||
<button type="submit" class="btn">Save name</button>
|
||||
</form>
|
||||
<form method="post" action="<%= to("/admin/settings/fediverse") %>" class="settings-inline-form" style="margin-top: 1rem;">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||
<div class="field">
|
||||
<label for="fediverse_creator">Fediverse handle</label>
|
||||
<input id="fediverse_creator" class="input" type="text" name="fediverse_creator" value="<%= h(current_user_record&.fediverse_creator) %>" placeholder="@user@instance.tld">
|
||||
</div>
|
||||
<button type="submit" class="btn">Save handle</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="settings-section-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
</div>
|
||||
<div class="settings-section-body">
|
||||
<h3>Security</h3>
|
||||
<p class="settings-section-desc">Change your password and username. You can also lock yourself out.</p>
|
||||
<form method="post" action="<%= to("/admin/settings/password") %>" class="settings-inline-form">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="current_password">Current password</label>
|
||||
<input id="current_password" class="input" type="password" name="current_password" autocomplete="current-password">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="new_password">New password</label>
|
||||
<input id="new_password" class="input" type="password" name="new_password" autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn">Update password</button>
|
||||
</form>
|
||||
<form method="post" action="<%= to("/admin/settings/username") %>" class="settings-inline-form" style="margin-top: 1rem;">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||
<div class="field">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" class="input" type="text" name="username" value="<%= h(current_user) %>">
|
||||
</div>
|
||||
<button type="submit" class="btn">Update username</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<% if admin? %>
|
||||
<section id="users" class="surface">
|
||||
<div class="surface-head">
|
||||
<div>
|
||||
<h2>Users</h2>
|
||||
<p class="lede">Add or remove admin and editor accounts</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="users-list">
|
||||
<% @users.each do |user| %>
|
||||
<div class="user-row">
|
||||
<% if user.photo %>
|
||||
<img src="<%= h(user.photo) %>" alt="" class="user-avatar">
|
||||
<% else %>
|
||||
<div class="user-avatar user-avatar-placeholder"><%= h((user.name || user.username).to_s[0].upcase) %></div>
|
||||
<% end %>
|
||||
<div class="user-info">
|
||||
<div class="user-name">
|
||||
<%= h(user.name || user.username) %>
|
||||
<% if user.username == current_user %><span class="badge badge-accent" style="margin-left: 0.4rem;">you</span><% end %>
|
||||
</div>
|
||||
<div class="user-username">@<%= h(user.username) %></div>
|
||||
</div>
|
||||
<div class="user-role">
|
||||
<% if user.username == current_user %>
|
||||
<span class="badge badge-neutral"><%= h(user.role) %></span>
|
||||
<% else %>
|
||||
<form method="post" action="<%= h(to("/admin/settings/users/#{user.username}/role")) %>" style="margin: 0;">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||
<select class="select select-sm" name="role" onchange="this.form.submit()">
|
||||
<% Volumen::Users::ROLES.each do |role| %>
|
||||
<option value="<%= role %>" <%= user.role == role ? "selected" : "" %>><%= role %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</form>
|
||||
<% end %>
|
||||
</div>
|
||||
<% unless user.username == current_user %>
|
||||
<form method="post" action="<%= h(to("/admin/settings/users/#{user.username}/delete")) %>" onsubmit="return confirm('Remove this user?');" style="margin: 0;">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||
<button type="submit" class="btn btn-sm btn-ghost btn-danger">Remove</button>
|
||||
</form>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="add-user-form">
|
||||
<h3 style="margin: 0 0 0.75rem;">Add user</h3>
|
||||
<form method="post" action="<%= to("/admin/settings/users") %>">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||
<div class="field-row-3">
|
||||
<div class="field">
|
||||
<label for="new-username">Username</label>
|
||||
<input id="new-username" class="input" type="text" name="username" placeholder="jane-doe">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="new-password">Password</label>
|
||||
<input id="new-password" class="input" type="password" name="password" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="new-role">Role</label>
|
||||
<select id="new-role" class="select" name="role">
|
||||
<% Volumen::Users::ROLES.each do |role| %>
|
||||
<option value="<%= role %>" <%= role == Volumen::Users::DEFAULT_ROLE ? "selected" : "" %>><%= role %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add user</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<form method="post" action="<%= to("/admin/settings/password") %>">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||
<h3 style="margin: 0 0 0.75rem;">Change password</h3>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="current_password">Current password</label>
|
||||
<input id="current_password" class="input" type="password" name="current_password" autocomplete="current-password">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="new_password">New password</label>
|
||||
<input id="new_password" class="input" type="password" name="new_password" autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Update password</button>
|
||||
</form>
|
||||
|
||||
<div style="border-top: 1px solid var(--border); margin: 1.5rem 0;"></div>
|
||||
|
||||
<form method="post" action="<%= to("/admin/settings/name") %>">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||
<h3 style="margin: 0 0 0.75rem;">Display name</h3>
|
||||
<div class="field">
|
||||
<label for="name">Real name</label>
|
||||
<input id="name" class="input" type="text" name="name" value="<%= h(current_user_record&.name) %>" placeholder="Your real name" style="max-width: 360px;">
|
||||
<p class="help">Used to pre-fill the author field for new posts. Leave empty to use your username.</p>
|
||||
</div>
|
||||
<button type="submit" class="btn">Save name</button>
|
||||
</form>
|
||||
|
||||
<div style="border-top: 1px solid var(--border); margin: 1.5rem 0;"></div>
|
||||
|
||||
<form method="post" action="<%= to("/admin/settings/fediverse") %>">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||
<h3 style="margin: 0 0 0.75rem;">Fediverse handle</h3>
|
||||
<div class="field">
|
||||
<label for="fediverse_creator">Fediverse creator</label>
|
||||
<input id="fediverse_creator" class="input" type="text" name="fediverse_creator"
|
||||
value="<%= h(current_user_record&.fediverse_creator) %>"
|
||||
placeholder="@user@instance.tld" style="max-width: 360px;">
|
||||
<p class="help">Used to pre-fill the Fediverse creator for new posts.</p>
|
||||
</div>
|
||||
<button type="submit" class="btn">Save handle</button>
|
||||
</form>
|
||||
|
||||
<div style="border-top: 1px solid var(--border); margin: 1.5rem 0;"></div>
|
||||
|
||||
<form method="post" action="<%= to("/admin/settings/username") %>">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||
<h3 style="margin: 0 0 0.75rem;">Change username</h3>
|
||||
<div class="field">
|
||||
<label for="username">New username</label>
|
||||
<input id="username" class="input" type="text" name="username" value="<%= h(current_user) %>" style="max-width: 360px;">
|
||||
</div>
|
||||
<button type="submit" class="btn">Update username</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<% if admin? %>
|
||||
<div class="surface">
|
||||
<div class="surface-head">
|
||||
<div>
|
||||
<h2>Users</h2>
|
||||
<p class="lede">Add or remove admin and editor accounts</p>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var sections = document.querySelectorAll(".settings-panels .surface[id]");
|
||||
var navLinks = document.querySelectorAll(".settings-nav a[href^='#']");
|
||||
if (!sections.length || !navLinks.length) return;
|
||||
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th>Username</th><th>Role</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @users.each do |user| %>
|
||||
<tr>
|
||||
<td>
|
||||
<strong><%= h(user.username) %></strong>
|
||||
<% if user.username == current_user %><span class="badge badge-accent" style="margin-left: 0.4rem;">you</span><% end %>
|
||||
</td>
|
||||
<td>
|
||||
<% if user.username == current_user %>
|
||||
<span class="badge badge-neutral"><%= h(user.role) %></span>
|
||||
<% else %>
|
||||
<form method="post"
|
||||
action="<%= h(to("/admin/settings/users/#{user.username}/role")) %>"
|
||||
style="margin: 0;">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||
<select class="select" name="role" onchange="this.form.submit()" style="width: 130px; padding: 0.35rem 0.6rem; font-size: 0.82rem;">
|
||||
<% Volumen::Users::ROLES.each do |role| %>
|
||||
<option value="<%= role %>" <%= user.role == role ? "selected" : "" %>><%= role %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</form>
|
||||
<% end %>
|
||||
</td>
|
||||
<td>
|
||||
<% unless user.username == current_user %>
|
||||
<form method="post"
|
||||
action="<%= h(to("/admin/settings/users/#{user.username}/delete")) %>"
|
||||
onsubmit="return confirm('Remove this user?');"
|
||||
style="margin: 0;">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Remove</button>
|
||||
</form>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
function showSection(id) {
|
||||
sections.forEach(function (s) {
|
||||
s.hidden = s.id !== id;
|
||||
});
|
||||
navLinks.forEach(function (a) {
|
||||
a.classList.toggle("active", a.getAttribute("href") === "#" + id);
|
||||
});
|
||||
try { history.replaceState(null, "", "#" + id); } catch (e) {}
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
<div style="border-top: 1px solid var(--border); margin: 1.5rem 0;"></div>
|
||||
navLinks.forEach(function (link) {
|
||||
link.addEventListener("click", function (event) {
|
||||
event.preventDefault();
|
||||
var id = link.getAttribute("href").slice(1);
|
||||
showSection(id);
|
||||
});
|
||||
});
|
||||
|
||||
<form method="post" action="<%= to("/admin/settings/users") %>">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf_token %>">
|
||||
<h3 style="margin: 0 0 0.75rem;">Add user</h3>
|
||||
<div class="field-row-3">
|
||||
<div class="field">
|
||||
<label for="new-username">Username</label>
|
||||
<input id="new-username" class="input" type="text" name="username">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="new-password">Password</label>
|
||||
<input id="new-password" class="input" type="password" name="password" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="new-role">Role</label>
|
||||
<select id="new-role" class="select" name="role">
|
||||
<% Volumen::Users::ROLES.each do |role| %>
|
||||
<option value="<%= role %>" <%= role == Volumen::Users::DEFAULT_ROLE ? "selected" : "" %>><%= role %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Add user</button>
|
||||
</form>
|
||||
</div>
|
||||
<% end %>
|
||||
var initial = (window.location.hash || "").slice(1);
|
||||
var hasInitial = Array.from(sections).some(function (s) { return s.id === initial; });
|
||||
showSection(hasInitial ? initial : sections[0].id);
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user