[skip ci] feat: prepare for public release

This commit is contained in:
Hampus Kraft
2026-01-02 19:27:51 +00:00
parent 197b23757f
commit 5ae825fc7d
199 changed files with 38391 additions and 33358 deletions

View File

@@ -25,8 +25,11 @@ import gleam/dynamic/decode
import gleam/http
import gleam/http/request
import gleam/httpc
import gleam/int
import gleam/io
import gleam/json
import gleam/option
import gleam/string
pub type Report {
Report(
@@ -194,10 +197,7 @@ pub fn list_reports(
"reported_guild_name",
decode.optional(decode.string),
)
use reported_guild_icon_hash <- decode.field(
"reported_guild_icon_hash",
decode.optional(decode.string),
)
let reported_guild_icon_hash = option.None
use reported_guild_invite_code <- decode.field(
"reported_guild_invite_code",
decode.optional(decode.string),
@@ -518,14 +518,15 @@ pub fn get_report_detail(
let context_message_decoder = {
use id <- decode.field("id", decode.string)
use channel_id <- decode.field(
"channel_id",
decode.optional(decode.string),
use channel_id <- decode.optional_field("channel_id", "", decode.string)
use author_id <- decode.optional_field("author_id", "", decode.string)
use author_username <- decode.optional_field(
"author_username",
"",
decode.string,
)
use author_id <- decode.field("author_id", decode.string)
use author_username <- decode.field("author_username", decode.string)
use content <- decode.field("content", decode.string)
use timestamp <- decode.field("timestamp", decode.string)
use content <- decode.optional_field("content", "", decode.string)
use timestamp <- decode.optional_field("timestamp", "", decode.string)
use attachments <- decode.optional_field(
"attachments",
[],
@@ -533,7 +534,7 @@ pub fn get_report_detail(
)
decode.success(Message(
id: id,
channel_id: option.unwrap(channel_id, ""),
channel_id: channel_id,
author_id: author_id,
author_username: author_username,
content: content,
@@ -608,8 +609,9 @@ pub fn get_report_detail(
"reported_guild_name",
decode.optional(decode.string),
)
use reported_guild_icon_hash <- decode.field(
use reported_guild_icon_hash <- decode.optional_field(
"reported_guild_icon_hash",
option.None,
decode.optional(decode.string),
)
use reported_guild_invite_code <- decode.field(
@@ -680,7 +682,15 @@ pub fn get_report_detail(
case json.parse(resp.body, report_decoder) {
Ok(result) -> Ok(result)
Error(_) -> Error(ServerError)
Error(err) -> {
io.println(
"reports.get_report_detail decode failed: "
<> string.inspect(err)
<> " body="
<> string.slice(resp.body, 0, 4000),
)
Error(ServerError)
}
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
@@ -699,7 +709,20 @@ pub fn get_report_detail(
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
Ok(resp) -> {
io.println(
"reports.get_report_detail unexpected status "
<> int.to_string(resp.status)
<> " body="
<> string.slice(resp.body, 0, 1000),
)
Error(ServerError)
}
Error(err) -> {
io.println(
"reports.get_report_detail network error: " <> string.inspect(err),
)
Error(NetworkError)
}
}
}

View File

@@ -100,21 +100,36 @@ pub fn page_with_refresh(
[a.attribute("lang", "en"), a.attribute("data-base-path", ctx.base_path)],
[
build_head_with_refresh(title, ctx, auto_refresh),
h.body([a.class("min-h-screen bg-neutral-50 flex")], [
sidebar(ctx, active_page),
h.div([a.class("ml-64 flex-1 flex flex-col")], [
header(ctx, session, current_admin),
h.main([a.class("flex-1 p-8")], [
h.div([a.class("max-w-7xl mx-auto")], [
case flash_data {
option.Some(_) ->
h.div([a.class("mb-6")], [flash.view(flash_data)])
option.None -> element.none()
},
content,
]),
]),
h.body([a.class("min-h-screen bg-neutral-50 overflow-hidden")], [
h.div([a.class("flex h-screen")], [
sidebar(ctx, active_page),
h.div(
[
a.attribute("data-sidebar-overlay", ""),
a.class("fixed inset-0 bg-black/50 z-30 hidden lg:hidden"),
],
[],
),
h.div(
[
a.class("flex-1 flex flex-col w-full h-screen overflow-y-auto"),
],
[
header(ctx, session, current_admin),
h.main([a.class("flex-1 p-4 sm:p-6 lg:p-8")], [
h.div([a.class("w-full max-w-7xl mx-auto")], [
case flash_data {
option.Some(_) ->
h.div([a.class("mb-6")], [flash.view(flash_data)])
option.None -> element.none()
},
content,
]),
]),
],
),
]),
sidebar_interaction_script(),
]),
],
)
@@ -123,18 +138,37 @@ pub fn page_with_refresh(
fn sidebar(ctx: Context, active_page: String) {
h.div(
[
a.attribute("data-sidebar", ""),
a.class(
"w-64 bg-neutral-900 text-white flex flex-col h-screen fixed left-0 top-0",
"fixed inset-y-0 left-0 z-40 w-64 h-screen bg-neutral-900 text-white flex flex-col transform -translate-x-full transition-transform duration-200 ease-in-out lg:translate-x-0 lg:static lg:inset-auto shadow-xl lg:shadow-none",
),
],
[
h.div([a.class("p-6 border-b border-neutral-800")], [
h.a([href(ctx, "/users")], [
h.h1([a.class("text-base font-semibold")], [
element.text("Fluxer Admin"),
h.div(
[
a.class(
"p-6 border-b border-neutral-800 flex items-center justify-between gap-3",
),
],
[
h.a([href(ctx, "/users")], [
h.h1([a.class("text-base font-semibold")], [
element.text("Fluxer Admin"),
]),
]),
]),
]),
h.button(
[
a.type_("button"),
a.attribute("data-sidebar-close", ""),
a.class(
"lg:hidden inline-flex items-center justify-center p-2 rounded-md text-neutral-200 hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-white/40",
),
a.attribute("aria-label", "Close sidebar"),
],
[element.text("Close")],
),
],
),
h.nav(
[
a.class("flex-1 overflow-y-auto p-4 space-y-1 sidebar-scrollbar"),
@@ -304,11 +338,68 @@ fn header(
h.header(
[
a.class(
"bg-white border-b border-neutral-200 px-8 py-4 flex items-center justify-between",
"bg-white border-b border-neutral-200 px-4 sm:px-6 lg:px-8 py-4 flex items-center justify-between gap-4 sticky top-0 z-10",
),
],
[
render_user_info(ctx, session, current_admin),
h.div([a.class("flex items-center gap-3 min-w-0")], [
h.button(
[
a.type_("button"),
a.attribute("data-sidebar-toggle", ""),
a.class(
"lg:hidden inline-flex items-center justify-center p-2 rounded-md border border-neutral-300 text-neutral-700 hover:bg-neutral-100 focus:outline-none focus:ring-2 focus:ring-neutral-400",
),
a.attribute("aria-label", "Toggle sidebar"),
],
[
element.element(
"svg",
[
a.attribute("xmlns", "http://www.w3.org/2000/svg"),
a.attribute("viewBox", "0 0 24 24"),
a.class("w-5 h-5"),
a.attribute("fill", "none"),
a.attribute("stroke", "currentColor"),
a.attribute("stroke-width", "2"),
],
[
element.element(
"line",
[
a.attribute("x1", "3"),
a.attribute("y1", "6"),
a.attribute("x2", "21"),
a.attribute("y2", "6"),
],
[],
),
element.element(
"line",
[
a.attribute("x1", "3"),
a.attribute("y1", "12"),
a.attribute("x2", "21"),
a.attribute("y2", "12"),
],
[],
),
element.element(
"line",
[
a.attribute("x1", "3"),
a.attribute("y1", "18"),
a.attribute("x2", "21"),
a.attribute("y2", "18"),
],
[],
),
],
),
],
),
render_user_info(ctx, session, current_admin),
]),
h.a(
[
href(ctx, "/logout"),
@@ -390,3 +481,58 @@ fn render_avatar(
a.class("w-10 h-10 rounded-full"),
])
}
fn sidebar_interaction_script() {
h.script(
[a.attribute("defer", "defer")],
"
(function() {
const sidebar = document.querySelector('[data-sidebar]');
const overlay = document.querySelector('[data-sidebar-overlay]');
const toggles = document.querySelectorAll('[data-sidebar-toggle]');
const closes = document.querySelectorAll('[data-sidebar-close]');
if (!sidebar || !overlay) return;
const open = () => {
sidebar.classList.remove('-translate-x-full');
overlay.classList.remove('hidden');
document.body.classList.add('overflow-hidden');
};
const close = () => {
sidebar.classList.add('-translate-x-full');
overlay.classList.add('hidden');
document.body.classList.remove('overflow-hidden');
};
toggles.forEach((btn) => btn.addEventListener('click', () => {
if (sidebar.classList.contains('-translate-x-full')) {
open();
} else {
close();
}
}));
closes.forEach((btn) => btn.addEventListener('click', close));
overlay.addEventListener('click', close);
window.addEventListener('keydown', (event) => {
if (event.key === 'Escape') close();
});
const syncForDesktop = () => {
if (window.innerWidth >= 1024) {
overlay.classList.add('hidden');
document.body.classList.remove('overflow-hidden');
sidebar.classList.remove('-translate-x-full');
} else {
sidebar.classList.add('-translate-x-full');
}
};
window.addEventListener('resize', syncForDesktop);
syncForDesktop();
})();
",
)
}

View File

@@ -28,7 +28,7 @@ pub type Tab {
pub fn render_tabs(ctx: Context, tabs: List(Tab)) -> element.Element(a) {
h.div([a.class("border-b border-neutral-200 mb-6")], [
h.nav(
[a.class("flex gap-6")],
[a.class("flex gap-6 overflow-x-auto no-scrollbar -mb-px px-1")],
list.map(tabs, fn(tab) { render_tab(ctx, tab) }),
),
])
@@ -36,9 +36,10 @@ pub fn render_tabs(ctx: Context, tabs: List(Tab)) -> element.Element(a) {
fn render_tab(ctx: Context, tab: Tab) -> element.Element(a) {
let class_active = case tab.active {
True -> "border-b-2 border-neutral-900 text-neutral-900 text-sm pb-3"
True ->
"border-b-2 border-neutral-900 text-neutral-900 text-sm pb-3 whitespace-nowrap"
False ->
"border-b-2 border-transparent text-neutral-600 hover:text-neutral-900 hover:border-neutral-300 text-sm pb-3 transition-colors"
"border-b-2 border-transparent text-neutral-600 hover:text-neutral-900 hover:border-neutral-300 text-sm pb-3 transition-colors whitespace-nowrap"
}
h.a([href(ctx, tab.path), a.class(class_active)], [element.text(tab.label)])

View File

@@ -23,7 +23,7 @@ import lustre/attribute as a
import lustre/element
import lustre/element/html as h
pub const table_container_class = "bg-white border border-neutral-200 rounded-lg overflow-hidden"
pub const table_container_class = "bg-white border border-neutral-200 rounded-lg overflow-hidden overflow-x-auto"
pub const table_header_cell_class = "px-6 py-3 text-left text-xs text-neutral-600 uppercase tracking-wider"
@@ -469,7 +469,10 @@ pub fn flex_row(
pub fn flex_row_between(
children: List(element.Element(a)),
) -> element.Element(a) {
h.div([a.class("mb-6 flex items-center justify-between")], children)
h.div(
[a.class("mb-6 flex flex-wrap items-center justify-between gap-3")],
children,
)
}
pub fn stack(
@@ -570,35 +573,37 @@ pub fn data_table(
h.div(
[a.class("bg-white border border-neutral-200 rounded-lg overflow-hidden")],
[
h.table([a.class("min-w-full divide-y divide-neutral-200")], [
h.thead([a.class("bg-neutral-50")], [
h.tr(
[],
list.map(columns, fn(col) {
let TableColumn(header, _, _) = col
h.th(
[
a.class(
"px-6 py-3 text-left text-xs text-neutral-600 uppercase tracking-wider",
),
],
[element.text(header)],
h.div([a.class("overflow-x-auto")], [
h.table([a.class("min-w-full divide-y divide-neutral-200")], [
h.thead([a.class("bg-neutral-50")], [
h.tr(
[],
list.map(columns, fn(col) {
let TableColumn(header, _, _) = col
h.th(
[
a.class(
"px-6 py-3 text-left text-xs text-neutral-600 uppercase tracking-wider",
),
],
[element.text(header)],
)
}),
),
]),
h.tbody(
[a.class("bg-white divide-y divide-neutral-200")],
list.map(rows, fn(row) {
h.tr(
[a.class("hover:bg-neutral-50 transition-colors")],
list.map(columns, fn(col) {
let TableColumn(_, cell_class, render) = col
h.td([a.class(cell_class)], [render(row)])
}),
)
}),
),
]),
h.tbody(
[a.class("bg-white divide-y divide-neutral-200")],
list.map(rows, fn(row) {
h.tr(
[a.class("hover:bg-neutral-50 transition-colors")],
list.map(columns, fn(col) {
let TableColumn(_, cell_class, render) = col
h.td([a.class(cell_class)], [render(row)])
}),
)
}),
),
]),
],
)
@@ -629,7 +634,7 @@ pub fn custom_checkbox(
]
}
h.label([a.class("flex items-center gap-3 cursor-pointer group")], [
h.label([a.class("flex items-center gap-3 cursor-pointer group w-full")], [
h.input(checkbox_attrs),
element.element(
"svg",
@@ -637,7 +642,7 @@ pub fn custom_checkbox(
a.attribute("xmlns", "http://www.w3.org/2000/svg"),
a.attribute("viewBox", "0 0 256 256"),
a.class(
"w-5 h-5 bg-white border-2 border-neutral-300 rounded p-0.5 text-white peer-checked:bg-neutral-900 peer-checked:border-neutral-900 transition-colors",
"w-5 h-5 bg-white border-2 border-neutral-300 rounded p-0.5 text-white peer-checked:bg-neutral-900 peer-checked:border-neutral-900 transition-colors flex-shrink-0",
),
],
[
@@ -655,8 +660,15 @@ pub fn custom_checkbox(
),
],
),
h.span([a.class("text-sm text-neutral-900 group-hover:text-neutral-700")], [
element.text(label),
h.div([a.class("flex-1 min-w-0")], [
h.span(
[
a.class(
"text-sm text-neutral-900 group-hover:text-neutral-700 leading-snug truncate",
),
],
[element.text(label)],
),
]),
])
}

View File

@@ -259,6 +259,10 @@ pub const acl_user_delete = "user:delete"
pub const acl_user_cancel_bulk_message_deletion = "user:cancel:bulk_message_deletion"
pub const acl_pending_verification_view = "pending_verification:view"
pub const acl_pending_verification_review = "pending_verification:review"
pub const acl_beta_codes_generate = "beta_codes:generate"
pub const acl_gift_codes_generate = "gift_codes:generate"
@@ -506,6 +510,8 @@ pub fn get_all_acls() -> List(String) {
acl_user_disable_suspicious,
acl_user_delete,
acl_user_cancel_bulk_message_deletion,
acl_pending_verification_view,
acl_pending_verification_review,
acl_beta_codes_generate,
acl_gift_codes_generate,
acl_guild_lookup,

View File

@@ -118,60 +118,81 @@ pub fn view(
h.div(
[a.class("bg-white border border-neutral-200 rounded-lg p-6 mb-6")],
[
h.div([a.class("flex items-start gap-6")], [
case
avatar.get_guild_icon_url(
ctx.media_endpoint,
guild_data.id,
guild_data.icon,
True,
)
{
option.Some(icon_url) ->
h.div([a.class("flex-shrink-0")], [
h.img([
a.src(icon_url),
a.alt(guild_data.name),
a.class("w-24 h-24 rounded-full"),
]),
])
option.None ->
h.div([a.class("flex-shrink-0")], [
h.div(
[
a.class(
"flex flex-col gap-4 sm:flex-row sm:items-start sm:gap-6",
),
],
[
case
avatar.get_guild_icon_url(
ctx.media_endpoint,
guild_data.id,
guild_data.icon,
True,
)
{
option.Some(icon_url) ->
h.div(
[
a.class(
"w-24 h-24 rounded-full bg-neutral-200 flex items-center justify-center text-base font-semibold text-neutral-600",
"flex-shrink-0 flex items-center sm:block justify-center",
),
],
[
element.text(avatar.get_initials_from_name(
guild_data.name,
)),
h.img([
a.src(icon_url),
a.alt(guild_data.name),
a.class("w-24 h-24 rounded-full"),
]),
],
),
])
},
ui.detail_header(guild_data.name, [
#(
"Guild ID:",
h.div([a.class("text-sm text-neutral-900")], [
element.text(guild_data.id),
]),
),
#(
"Owner ID:",
h.a(
[
href(ctx, "/users/" <> guild_data.owner_id),
a.class(
"text-sm text-neutral-900 hover:text-blue-600 hover:underline",
),
],
[element.text(guild_data.owner_id)],
)
option.None ->
h.div(
[
a.class(
"flex-shrink-0 flex items-center sm:block justify-center",
),
],
[
h.div(
[
a.class(
"w-24 h-24 rounded-full bg-neutral-200 flex items-center justify-center text-base font-semibold text-neutral-600",
),
],
[
element.text(avatar.get_initials_from_name(
guild_data.name,
)),
],
),
],
)
},
ui.detail_header(guild_data.name, [
#(
"Guild ID:",
h.div([a.class("text-sm text-neutral-900 break-all")], [
element.text(guild_data.id),
]),
),
),
]),
]),
#(
"Owner ID:",
h.a(
[
href(ctx, "/users/" <> guild_data.owner_id),
a.class(
"text-sm text-neutral-900 hover:text-blue-600 hover:underline",
),
],
[element.text(guild_data.owner_id)],
),
),
]),
],
),
],
),
render_tabs(

View File

@@ -112,7 +112,7 @@ pub fn view(
fn render_search_form(ctx: Context, query: option.Option(String)) {
ui.card(ui.PaddingSmall, [
h.form([a.method("get"), a.class("flex flex-col gap-4")], [
h.div([a.class("flex gap-2")], [
h.div([a.class("flex flex-col sm:flex-row gap-2")], [
h.input([
a.type_("text"),
a.name("q"),
@@ -123,12 +123,12 @@ fn render_search_form(ctx: Context, query: option.Option(String)) {
),
a.attribute("autocomplete", "off"),
]),
ui.button_primary("Search", "submit", []),
ui.button_primary("Search", "submit", [a.class("w-full sm:w-auto")]),
h.a(
[
href(ctx, "/guilds"),
a.class(
"px-4 py-2 bg-white text-neutral-700 border border-neutral-300 rounded-lg text-sm font-medium hover:bg-neutral-50 transition-colors",
"px-4 py-2 bg-white text-neutral-700 border border-neutral-300 rounded-lg text-sm font-medium hover:bg-neutral-50 transition-colors w-full sm:w-auto text-center",
),
],
[element.text("Clear")],
@@ -159,7 +159,7 @@ fn render_guild_card(ctx: Context, guild: guilds.GuildSearchResult) {
],
[
h.div([a.class("p-5")], [
h.div([a.class("flex items-center gap-4")], [
h.div([a.class("flex flex-col sm:flex-row sm:items-center gap-4")], [
case
avatar.get_guild_icon_url(
ctx.media_endpoint,
@@ -169,27 +169,41 @@ fn render_guild_card(ctx: Context, guild: guilds.GuildSearchResult) {
)
{
option.Some(icon_url) ->
h.div([a.class("flex-shrink-0")], [
h.img([
a.src(icon_url),
a.alt(guild.name),
a.class("w-16 h-16 rounded-full"),
]),
])
h.div(
[
a.class(
"flex-shrink-0 flex items-center sm:block justify-center",
),
],
[
h.img([
a.src(icon_url),
a.alt(guild.name),
a.class("w-16 h-16 rounded-full"),
]),
],
)
option.None ->
h.div([a.class("flex-shrink-0")], [
h.div(
[
a.class(
"w-16 h-16 rounded-full bg-neutral-200 flex items-center justify-center text-base font-medium text-neutral-600",
),
],
[element.text(avatar.get_initials_from_name(guild.name))],
),
])
h.div(
[
a.class(
"flex-shrink-0 flex items-center sm:block justify-center",
),
],
[
h.div(
[
a.class(
"w-16 h-16 rounded-full bg-neutral-200 flex items-center justify-center text-base font-medium text-neutral-600",
),
],
[element.text(avatar.get_initials_from_name(guild.name))],
),
],
)
},
h.div([a.class("flex-1 min-w-0")], [
h.div([a.class("flex items-center gap-2 mb-2")], [
h.div([a.class("flex items-center gap-2 mb-2 flex-wrap")], [
h.h2([a.class("text-base font-medium text-neutral-900")], [
element.text(guild.name),
]),
@@ -207,7 +221,7 @@ fn render_guild_card(ctx: Context, guild: guilds.GuildSearchResult) {
},
]),
h.div([a.class("space-y-0.5")], [
h.div([a.class("text-sm text-neutral-600")], [
h.div([a.class("text-sm text-neutral-600 break-all")], [
element.text("ID: " <> guild.id),
]),
h.div([a.class("text-sm text-neutral-600")], [

View File

@@ -15,18 +15,16 @@
//// You should have received a copy of the GNU Affero General Public License
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
import fluxer_admin/acl
import fluxer_admin/api/common
import fluxer_admin/api/verifications
import fluxer_admin/avatar
import fluxer_admin/components/flash
import fluxer_admin/components/layout
import fluxer_admin/components/review_deck
import fluxer_admin/components/review_hintbar
import fluxer_admin/components/ui
import fluxer_admin/constants
import fluxer_admin/user
import fluxer_admin/web.{
type Context, type Session, action, href, prepend_base_path,
}
import fluxer_admin/web.{type Context, type Session, action, href}
import gleam/int
import gleam/list
import gleam/option
@@ -47,6 +45,60 @@ const suspicious_user_agent_keywords = [
"go-http-client",
]
fn selection_toolbar() -> element.Element(a) {
h.div(
[
a.class(
"mt-4 flex items-center justify-between gap-3 bg-neutral-50 border border-neutral-200 rounded-lg px-3 py-2",
),
a.attribute("data-selection-toolbar", "true"),
],
[
h.div([a.class("flex items-center gap-3")], [
h.input([
a.type_("checkbox"),
a.attribute("data-select-all", "true"),
a.class("h-4 w-4 rounded border-neutral-300"),
]),
h.span([a.class("text-sm text-neutral-700")], [
element.text("Select all visible"),
]),
]),
h.div([a.class("flex items-center gap-3 flex-wrap justify-end")], [
h.span(
[
a.attribute("data-selected-count", "true"),
a.class("text-sm text-neutral-600"),
],
[element.text("0 selected")],
),
h.div([a.class("flex items-center gap-2")], [
h.button(
[
a.attribute("data-bulk-action", "reject"),
a.class(
"px-3 py-1.5 bg-red-600 text-white rounded-lg text-sm disabled:opacity-40 disabled:cursor-not-allowed",
),
a.disabled(True),
],
[element.text("Reject selected")],
),
h.button(
[
a.attribute("data-bulk-action", "approve"),
a.class(
"px-3 py-1.5 bg-green-600 text-white rounded-lg text-sm disabled:opacity-40 disabled:cursor-not-allowed",
),
a.disabled(True),
],
[element.text("Approve selected")],
),
]),
]),
],
)
}
pub fn view(
ctx: Context,
session: Session,
@@ -55,6 +107,12 @@ pub fn view(
) -> Response {
let limit = 50
let result = verifications.list_pending_verifications(ctx, session, limit)
let admin_acls = case current_admin {
option.Some(admin) -> admin.acls
option.None -> []
}
let can_review =
acl.has_permission(admin_acls, constants.acl_pending_verification_review)
let content = case result {
Ok(response) -> {
@@ -75,72 +133,31 @@ pub fn view(
h.span(
[
a.class("body-sm text-neutral-600"),
a.attribute("data-review-progress", ""),
],
[
element.text(int.to_string(count) <> " remaining"),
a.attribute("data-remaining-total", "true"),
],
[element.text(int.to_string(count) <> " remaining")],
)
},
]),
]),
case can_review {
True -> selection_toolbar()
False -> element.none()
},
case list.is_empty(response.pending_verifications) {
True -> empty_state()
False ->
h.div(
[a.class("mt-4")],
list.append(
[review_deck.styles()],
list.append(review_deck.script_tags(), [
h.div(
[
a.attribute("data-review-deck", "true"),
a.attribute(
"data-fragment-base",
prepend_base_path(
ctx,
"/pending-verifications/fragment",
),
),
a.attribute("data-next-page", "2"),
a.attribute("data-can-paginate", "true"),
a.attribute("data-prefetch-when-remaining", "6"),
a.attribute(
"data-empty-url",
prepend_base_path(ctx, "/pending-verifications"),
),
a.tabindex(0),
],
[
h.div(
[a.class("max-w-2xl mx-auto")],
list.map(response.pending_verifications, fn(pv) {
render_pending_verification_card(ctx, pv)
}),
),
h.div(
[
a.attribute("data-review-progress", "true"),
a.class("text-center mt-4 body-sm text-neutral-600"),
],
[
element.text(int.to_string(total) <> " remaining"),
],
),
review_hintbar.view(
"",
"Reject",
"",
"Approve",
"Esc",
"Exit",
option.Some("Swipe cards on touch devices"),
),
],
),
]),
h.div([a.class("mt-4")], [
h.div(
[
a.class("grid gap-4 md:grid-cols-2 xl:grid-cols-3"),
a.attribute("data-select-grid", "true"),
],
list.map(response.pending_verifications, fn(pv) {
render_pending_verification_card(ctx, pv, can_review)
}),
),
)
])
},
],
)
@@ -156,12 +173,17 @@ pub fn view(
session,
current_admin,
flash_data,
content,
h.div([], [content, pending_verifications_script()]),
)
wisp.html_response(element.to_document_string(html), 200)
}
pub fn view_fragment(ctx: Context, session: Session, page: Int) -> Response {
pub fn view_fragment(
ctx: Context,
session: Session,
page: Int,
can_review: Bool,
) -> Response {
let limit = 50
let _offset = page * limit
let result = verifications.list_pending_verifications(ctx, session, limit)
@@ -170,19 +192,17 @@ pub fn view_fragment(ctx: Context, session: Session, page: Int) -> Response {
Ok(response) -> {
let fragment =
h.div(
[
a.attribute("data-review-fragment", "true"),
a.attribute("data-page", int.to_string(page)),
],
[a.class("grid gap-4 md:grid-cols-2 xl:grid-cols-3")],
list.map(response.pending_verifications, fn(pv) {
render_pending_verification_card(ctx, pv)
render_pending_verification_card(ctx, pv, can_review)
}),
)
wisp.html_response(element.to_document_string(fragment), 200)
}
Error(_) -> {
let empty = h.div([a.attribute("data-review-fragment", "true")], [])
let empty =
h.div([a.class("grid gap-4 md:grid-cols-2 xl:grid-cols-3")], [])
wisp.html_response(element.to_document_string(empty), 200)
}
@@ -198,6 +218,12 @@ pub fn view_single(
) -> Response {
let limit = 50
let result = verifications.list_pending_verifications(ctx, session, limit)
let admin_acls = case current_admin {
option.Some(admin) -> admin.acls
option.None -> []
}
let can_review =
acl.has_permission(admin_acls, constants.acl_pending_verification_review)
let content = case result {
Ok(response) -> {
@@ -224,45 +250,13 @@ pub fn view_single(
),
]),
h.div(
[a.class("mt-4")],
list.append(
[review_deck.styles()],
list.append(review_deck.script_tags(), [
h.div(
[
a.attribute("data-review-deck", "true"),
a.attribute(
"data-empty-url",
prepend_base_path(ctx, "/pending-verifications"),
),
a.tabindex(0),
],
[
h.div([a.class("max-w-2xl mx-auto")], [
render_pending_verification_card(ctx, pv),
]),
h.div(
[
a.attribute("data-review-progress", "true"),
a.class("text-center mt-4 body-sm text-neutral-600"),
],
[
element.text("1 remaining"),
],
),
review_hintbar.view(
"",
"Reject",
"",
"Approve",
"Esc",
"Exit",
option.Some("Swipe cards on touch devices"),
),
],
),
]),
),
[
a.class("mt-4 max-w-3xl"),
a.attribute("data-select-grid", "true"),
],
[
render_pending_verification_card(ctx, pv, can_review),
],
),
],
)
@@ -289,7 +283,7 @@ pub fn view_single(
session,
current_admin,
flash_data,
content,
h.div([], [content, pending_verifications_script()]),
)
wisp.html_response(element.to_document_string(html), 200)
}
@@ -432,23 +426,30 @@ fn api_error_message(err: common.ApiError) -> String {
fn render_pending_verification_card(
ctx: Context,
pv: verifications.PendingVerification,
can_review: Bool,
) -> element.Element(a) {
let metadata_warning = user_agent_warning(pv.metadata)
let geoip_hint = geoip_reason_value(pv.metadata)
h.div(
[
a.attribute("data-review-card", "true"),
a.attribute(
"data-direct-url",
prepend_base_path(ctx, "/pending-verifications/" <> pv.user_id),
),
a.class(
"bg-white border border-neutral-200 rounded-xl shadow-sm p-6 focus:outline-none focus:ring-2 focus:ring-neutral-900",
),
a.tabindex(0),
a.attribute("data-select-card", pv.user_id),
],
[
h.div([a.class("flex items-start gap-4 mb-6")], [
case can_review {
True ->
h.input([
a.type_("checkbox"),
a.class("h-4 w-4 mt-1.5 rounded border-neutral-300"),
a.attribute("data-select-checkbox", pv.user_id),
])
False -> element.none()
},
h.img([
a.src(avatar.get_user_avatar_url(
ctx.media_endpoint,
@@ -487,6 +488,16 @@ fn render_pending_verification_card(
element.text("Registered " <> format_timestamp(pv.created_at)),
]),
]),
h.div([a.class("flex flex-col items-end gap-2 ml-auto")], [
case metadata_warning {
option.Some(msg) -> ui.pill(msg, ui.PillWarning)
option.None -> element.none()
},
case geoip_hint {
option.Some(hint) -> ui.pill("GeoIP: " <> hint, ui.PillInfo)
option.None -> element.none()
},
]),
]),
h.details(
[
@@ -515,63 +526,68 @@ fn render_pending_verification_card(
]),
],
),
h.form(
[
a.method("post"),
action(ctx, "/pending-verifications?action=reject"),
a.attribute("data-review-submit", "left"),
a.class("inline-flex w-full"),
],
[
h.input([
a.type_("hidden"),
a.name("user_id"),
a.value(pv.user_id),
]),
],
),
h.form(
[
a.method("post"),
action(ctx, "/pending-verifications?action=approve"),
a.attribute("data-review-submit", "right"),
a.class("inline-flex w-full"),
],
[
h.input([
a.type_("hidden"),
a.name("user_id"),
a.value(pv.user_id),
]),
],
),
h.div(
[
a.class(
"flex items-center justify-between gap-4 pt-4 border-t border-neutral-200",
),
],
[
h.button(
[
a.attribute("data-review-action", "left"),
a.class(
"px-4 py-2 bg-red-600 text-white rounded-lg label hover:bg-red-700 transition-colors",
case can_review {
True ->
h.div([a.class("pt-4 border-t border-neutral-200")], [
h.div([a.class("flex items-center justify-end gap-2 flex-wrap")], [
h.form(
[
a.method("post"),
action(ctx, "/pending-verifications?action=reject"),
a.attribute("data-async", "true"),
a.attribute("data-confirm", "Reject this registration?"),
],
[
h.input([
a.type_("hidden"),
a.name("user_id"),
a.value(pv.user_id),
]),
h.button(
[
a.type_("submit"),
a.attribute("data-action-type", "reject"),
a.attribute("accesskey", "r"),
a.class(
"px-4 py-2 bg-red-600 text-white rounded-lg label hover:bg-red-700 transition-colors",
),
],
[element.text("Reject")],
),
],
),
],
[element.text("Reject")],
),
h.button(
[
a.attribute("data-review-action", "right"),
a.class(
"px-4 py-2 bg-green-600 text-white rounded-lg label hover:bg-green-700 transition-colors",
h.form(
[
a.method("post"),
action(ctx, "/pending-verifications?action=approve"),
a.attribute("data-async", "true"),
],
[
h.input([
a.type_("hidden"),
a.name("user_id"),
a.value(pv.user_id),
]),
h.button(
[
a.type_("submit"),
a.attribute("data-action-type", "approve"),
a.attribute("accesskey", "a"),
a.class(
"px-4 py-2 bg-green-600 text-white rounded-lg label hover:bg-green-700 transition-colors",
),
],
[element.text("Approve")],
),
],
),
],
[element.text("Approve")],
),
],
),
]),
])
False ->
h.div([a.class("text-sm text-neutral-500 pt-2")], [
element.text("You need pending_verification:review to act"),
])
},
],
)
}
@@ -689,6 +705,19 @@ fn metadata_value(
})
}
fn geoip_reason_value(
metadata: List(verifications.PendingVerificationMetadata),
) -> option.Option(String) {
case metadata_value(metadata, "geoip_reason") {
option.Some(reason) ->
case reason {
"none" -> option.None
r -> option.Some(r)
}
option.None -> option.None
}
}
fn option_or_default(default: String, value: option.Option(String)) -> String {
case value {
option.Some(v) -> v
@@ -737,6 +766,190 @@ fn empty_state() {
])
}
fn pending_verifications_script() -> element.Element(a) {
let js =
"
(function () {
const grid = document.querySelector('[data-select-grid]');
if (!grid) return;
const toolbar = document.querySelector('[data-selection-toolbar]');
const remainingEl = document.querySelector('[data-remaining-total]');
const countEl = toolbar?.querySelector('[data-selected-count]') || null;
const selectAll = toolbar?.querySelector('[data-select-all]') || null;
const bulkButtons = toolbar
? Array.from(toolbar.querySelectorAll('[data-bulk-action]'))
: [];
function showToast(message, variant) {
const box = document.createElement('div');
box.className = 'fixed left-4 right-4 bottom-4 z-50';
box.innerHTML =
'<div class=\"max-w-xl mx-auto\">' +
'<div class=\"px-4 py-3 rounded-lg shadow border ' +
(variant === 'success'
? 'bg-green-50 border-green-200 text-green-800'
: 'bg-red-50 border-red-200 text-red-800') +
'\">' +
'<div class=\"text-sm font-semibold\">' +
(variant === 'success' ? 'Saved' : 'Action failed') +
'</div>' +
'<div class=\"text-sm mt-1 break-words\">' + (message || 'OK') + '</div>' +
'</div></div>';
document.body.appendChild(box);
setTimeout(() => box.remove(), 4200);
}
function updateRemaining() {
if (!remainingEl) return;
const total = grid.querySelectorAll('[data-select-card]').length;
remainingEl.textContent = total.toString() + ' remaining';
}
function updateSelection() {
const boxes = Array.from(grid.querySelectorAll('[data-select-checkbox]'));
const selected = boxes.filter((b) => b.checked);
if (countEl) countEl.textContent = selected.length + ' selected';
bulkButtons.forEach((btn) => (btn.disabled = selected.length === 0));
if (selectAll) {
selectAll.checked = selected.length > 0 && selected.length === boxes.length;
selectAll.indeterminate =
selected.length > 0 && selected.length < boxes.length;
}
}
function cardFor(id) {
return grid.querySelector('[data-select-card=\"' + id + '\"]');
}
function removeCard(id) {
const card = cardFor(id);
if (card) card.remove();
updateRemaining();
updateSelection();
if (grid.querySelectorAll('[data-select-card]').length === 0) {
grid.innerHTML =
'<div class=\"col-span-full border border-dashed border-neutral-200 rounded-lg p-8 text-center text-neutral-500\">All registration requests have been processed</div>';
}
}
function setButtonLoading(btn, loading) {
if (!btn) return;
btn.disabled = loading;
if (loading) {
btn.dataset.originalText = btn.textContent;
btn.textContent = 'Working…';
} else if (btn.dataset.originalText) {
btn.textContent = btn.dataset.originalText;
}
}
async function submitForm(form) {
const actionUrl = new URL(form.action, window.location.origin);
actionUrl.searchParams.set('background', '1');
const fd = new FormData(form);
const body = new URLSearchParams();
fd.forEach((v, k) => body.append(k, v));
const resp = await fetch(actionUrl.toString(), {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8' },
body: body.toString(),
credentials: 'same-origin',
});
if (!resp.ok && resp.status !== 204) {
let txt = '';
try { txt = await resp.text(); } catch (_) {}
throw new Error(txt || 'Request failed (' + resp.status + ')');
}
}
async function actOn(id, action) {
const form = grid.querySelector(
'[data-select-card=\"' + id + '\"] form[data-action-type=\"' + action + '\"]'
);
if (!form) throw new Error('Missing form for ' + action);
await submitForm(form);
removeCard(id);
}
async function handleBulk(action) {
const boxes = Array.from(grid.querySelectorAll('[data-select-checkbox]:checked'));
if (boxes.length === 0) return;
const confirmMsg =
action === 'reject'
? 'Reject ' + boxes.length + ' registration(s)?'
: 'Approve ' + boxes.length + ' registration(s)?';
if (!window.confirm(confirmMsg)) return;
const button = toolbar.querySelector('[data-bulk-action=\"' + action + '\"]');
setButtonLoading(button, true);
try {
for (const box of boxes) {
const id = box.getAttribute('data-select-checkbox');
if (!id) continue;
await actOn(id, action);
}
showToast('Completed ' + action + ' for ' + boxes.length + ' item(s)', 'success');
} catch (err) {
showToast(err && err.message ? err.message : String(err), 'error');
} finally {
setButtonLoading(button, false);
}
}
bulkButtons.forEach((btn) => {
btn.addEventListener('click', (e) => {
e.preventDefault();
const action = btn.getAttribute('data-bulk-action');
if (action) handleBulk(action);
});
});
if (selectAll) {
selectAll.addEventListener('change', (e) => {
const boxes = grid.querySelectorAll('[data-select-checkbox]');
boxes.forEach((b) => (b.checked = e.target.checked));
updateSelection();
});
}
grid.addEventListener('change', (e) => {
const target = e.target;
if (target && target.matches('[data-select-checkbox]')) {
updateSelection();
}
});
document.querySelectorAll('form[data-async]').forEach((form) => {
form.addEventListener('submit', (e) => {
e.preventDefault();
const confirmMsg = form.getAttribute('data-confirm');
if (confirmMsg && !window.confirm(confirmMsg)) return;
const btn = form.querySelector('button[type=\"submit\"]');
const id = form.querySelector('[name=\"user_id\"]')?.value;
const action = btn?.getAttribute('data-action-type') || 'action';
setButtonLoading(btn, true);
submitForm(form)
.then(() => {
if (id) removeCard(id);
showToast(
(action === 'approve' ? 'Approved ' : 'Rejected ') + (id || 'item'),
'success'
);
})
.catch((err) => showToast(err && err.message ? err.message : String(err), 'error'))
.finally(() => setButtonLoading(btn, false));
});
});
updateRemaining();
updateSelection();
})();
"
h.script([a.attribute("defer", "defer")], js)
}
fn error_view(err: common.ApiError) {
let #(title, message) = case err {
common.Unauthorized -> #(

File diff suppressed because it is too large Load Diff

View File

@@ -121,85 +121,99 @@ pub fn view(
h.div(
[a.class("bg-white border border-neutral-200 rounded-lg p-6 mb-6")],
[
h.div([a.class("flex items-start gap-6")], [
h.div([a.class("flex-shrink-0")], [
h.img([
a.src(avatar.get_user_avatar_url(
ctx.media_endpoint,
ctx.cdn_endpoint,
user_data.id,
user_data.avatar,
True,
ctx.asset_version,
)),
a.alt(user_data.username),
a.class("w-24 h-24 rounded-full"),
]),
]),
h.div([a.class("flex-1")], [
h.div([a.class("flex items-center gap-3 mb-3")], [
ui.heading_section(
user_data.username
<> "#"
<> user.format_discriminator(user_data.discriminator),
),
case user_data.bot {
True ->
h.span(
[
a.class(
"px-2 py-1 bg-blue-100 text-blue-700 text-sm font-medium rounded uppercase",
),
],
[element.text("Bot")],
)
False -> element.none()
},
]),
case list.is_empty(badges) {
False ->
h.div(
[a.class("flex items-center gap-2 mb-3")],
list.map(badges, fn(b) {
h.img([
a.src(b.icon),
a.alt(b.name),
a.title(b.name),
a.class("w-6 h-6"),
])
}),
)
True -> element.none()
},
h.div([a.class("flex flex-wrap items-start gap-4")], [
h.div([a.class("flex items-start gap-2")], [
h.div([a.class("text-sm font-medium text-neutral-600")], [
element.text("User ID:"),
]),
h.div([a.class("text-sm text-neutral-900")], [
element.text(user_data.id),
h.div(
[
a.class(
"flex flex-col gap-4 sm:flex-row sm:items-start sm:gap-6",
),
],
[
h.div(
[
a.class(
"flex-shrink-0 flex items-center sm:block justify-center",
),
],
[
h.img([
a.src(avatar.get_user_avatar_url(
ctx.media_endpoint,
ctx.cdn_endpoint,
user_data.id,
user_data.avatar,
True,
ctx.asset_version,
)),
a.alt(user_data.username),
a.class("w-24 h-24 rounded-full"),
]),
],
),
h.div([a.class("flex-1")], [
h.div([a.class("flex flex-wrap items-center gap-3 mb-3")], [
ui.heading_section(
user_data.username
<> "#"
<> user.format_discriminator(user_data.discriminator),
),
case user_data.bot {
True ->
h.span(
[
a.class(
"px-2 py-1 bg-blue-100 text-blue-700 text-sm font-medium rounded uppercase",
),
],
[element.text("Bot")],
)
False -> element.none()
},
]),
case user.extract_timestamp(user_data.id) {
Ok(created_at) ->
h.div([a.class("flex items-start gap-2")], [
h.div(
[
a.class("text-sm font-medium text-neutral-600"),
],
[
element.text("Created:"),
],
),
h.div([a.class("text-sm text-neutral-900")], [
element.text(created_at),
]),
])
Error(_) -> element.none()
case list.is_empty(badges) {
False ->
h.div(
[a.class("flex items-center gap-2 mb-3 flex-wrap")],
list.map(badges, fn(b) {
h.img([
a.src(b.icon),
a.alt(b.name),
a.title(b.name),
a.class("w-6 h-6"),
])
}),
)
True -> element.none()
},
h.div([a.class("flex flex-wrap items-start gap-4")], [
h.div([a.class("flex items-start gap-2 min-w-0")], [
h.div([a.class("text-sm font-medium text-neutral-600")], [
element.text("User ID:"),
]),
h.div([a.class("text-sm text-neutral-900 break-all")], [
element.text(user_data.id),
]),
]),
case user.extract_timestamp(user_data.id) {
Ok(created_at) ->
h.div([a.class("flex items-start gap-2")], [
h.div(
[
a.class("text-sm font-medium text-neutral-600"),
],
[
element.text("Created:"),
],
),
h.div([a.class("text-sm text-neutral-900")], [
element.text(created_at),
]),
])
Error(_) -> element.none()
},
]),
]),
]),
]),
],
),
],
),
render_tabs(

View File

@@ -114,7 +114,7 @@ pub fn view(
fn render_search_form(ctx: Context, query: option.Option(String)) {
ui.card(ui.PaddingSmall, [
h.form([a.method("get"), a.class("flex flex-col gap-4")], [
h.div([a.class("flex gap-2")], [
h.div([a.class("flex flex-col sm:flex-row gap-2")], [
h.input([
a.type_("text"),
a.name("q"),
@@ -125,12 +125,12 @@ fn render_search_form(ctx: Context, query: option.Option(String)) {
),
a.attribute("autocomplete", "off"),
]),
ui.button_primary("Search", "submit", []),
ui.button_primary("Search", "submit", [a.class("w-full sm:w-auto")]),
h.a(
[
href(ctx, "/users"),
a.class(
"px-4 py-2 bg-white text-neutral-700 border border-neutral-300 rounded-lg text-sm font-medium hover:bg-neutral-50 transition-colors",
"px-4 py-2 bg-white text-neutral-700 border border-neutral-300 rounded-lg text-sm font-medium hover:bg-neutral-50 transition-colors w-full sm:w-auto text-center",
),
],
[element.text("Clear")],

View File

@@ -161,6 +161,19 @@ fn get_bool_query(req: Request, key: String) -> Bool {
}
}
fn clamp_limit(limit: Int) -> Int {
let min = 10
let max = 200
case limit < min {
True -> min
False ->
case limit > max {
True -> max
False -> limit
}
}
}
fn api_error_message(err: common.ApiError) -> String {
case err {
common.Unauthorized -> "Unauthorized"
@@ -897,27 +910,71 @@ fn handle_authenticated_request(req: Request, ctx: Context) -> Response {
Get -> {
use user_session, current_admin <- with_session_and_admin(req, ctx)
let flash_data = flash.from_request(req)
let admin_acls = case current_admin {
option.Some(admin) -> admin.acls
_ -> []
}
pending_verifications_page.view(
ctx,
user_session,
current_admin,
flash_data,
)
case
acl.has_permission(
admin_acls,
constants.acl_pending_verification_view,
)
{
True ->
pending_verifications_page.view(
ctx,
user_session,
current_admin,
flash_data,
)
False ->
wisp.response(403)
|> wisp.string_body(
"Forbidden: requires pending_verification:view permission",
)
}
}
Post -> {
use user_session <- with_session(req, ctx)
let query = wisp.get_query(req)
let action = list.key_find(query, "action") |> option.from_result
let background = get_bool_query(req, "background")
let admin_result = users.get_current_admin(ctx, user_session)
let admin_acls = case admin_result {
Ok(option.Some(admin)) -> admin.acls
_ -> []
}
pending_verifications_page.handle_action(
req,
ctx,
user_session,
action,
background,
)
case
acl.has_permission(
admin_acls,
constants.acl_pending_verification_review,
)
{
True ->
pending_verifications_page.handle_action(
req,
ctx,
user_session,
action,
background,
)
False ->
case background {
True ->
wisp.json_response(
"{\"error\": \"Forbidden: requires pending_verification:review permission\"}",
403,
)
False ->
flash.redirect_with_error(
ctx,
"/pending-verifications",
"Forbidden: requires pending_verification:review permission",
)
}
}
}
_ -> wisp.method_not_allowed([Get, Post])
}
@@ -932,8 +989,32 @@ fn handle_authenticated_request(req: Request, ctx: Context) -> Response {
|> option.unwrap("1")
let page =
int.parse(page_str) |> option.from_result |> option.unwrap(1)
let admin_result = users.get_current_admin(ctx, user_session)
let admin_acls = case admin_result {
Ok(option.Some(admin)) -> admin.acls
_ -> []
}
let can_review =
acl.has_permission(
admin_acls,
constants.acl_pending_verification_review,
)
pending_verifications_page.view_fragment(ctx, user_session, page)
case
acl.has_permission(
admin_acls,
constants.acl_pending_verification_view,
)
{
True ->
pending_verifications_page.view_fragment(
ctx,
user_session,
page,
can_review,
)
False -> wisp.response(403) |> wisp.string_body("Forbidden")
}
}
_ -> wisp.method_not_allowed([Get])
}
@@ -985,34 +1066,39 @@ fn handle_authenticated_request(req: Request, ctx: Context) -> Response {
|> option.unwrap("0")
let page =
int.parse(page_str) |> option.from_result |> option.unwrap(0)
let fragment = get_bool_query(req, "fragment")
let table_view = get_bool_query(req, "view")
let sort =
list.key_find(query, "sort")
|> option.from_result
|> option.map(fn(s) {
case string.trim(s) {
"" -> option.None
v -> option.Some(v)
}
})
|> option.unwrap(option.None)
let limit_str =
list.key_find(query, "limit")
|> option.from_result
|> option.unwrap("50")
let limit =
int.parse(limit_str)
|> option.from_result
|> option.unwrap(50)
|> clamp_limit
case fragment {
True ->
reports_page.view_fragment(
ctx,
user_session,
search_query,
status_filter,
type_filter,
category_filter,
page,
)
False ->
reports_page.view_with_mode(
ctx,
user_session,
current_admin,
flash_data,
search_query,
status_filter,
type_filter,
category_filter,
page,
table_view,
)
}
reports_page.view_with_mode(
ctx,
user_session,
current_admin,
flash_data,
search_query,
status_filter,
type_filter,
category_filter,
page,
limit,
sort,
)
}
_ -> wisp.method_not_allowed([Get])
}