initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

48
fluxer_admin/Dockerfile Normal file
View File

@@ -0,0 +1,48 @@
ARG BUILD_TIMESTAMP=0
FROM erlang:27.1.1.0-alpine AS builder
COPY --from=ghcr.io/gleam-lang/gleam:nightly-erlang /bin/gleam /bin/gleam
RUN apk add --no-cache git curl
WORKDIR /app
COPY gleam.toml manifest.toml ./
COPY src ./src
COPY priv ./priv
COPY tailwind.css ./
RUN gleam deps download
RUN gleam export erlang-shipment
ARG TAILWIND_VERSION=v4.1.17
RUN ARCH=$(uname -m) && \
if [ "$ARCH" = "x86_64" ]; then \
TAILWIND_ARCH="x64"; \
elif [ "$ARCH" = "aarch64" ]; then \
TAILWIND_ARCH="arm64"; \
else \
TAILWIND_ARCH="x64"; \
fi && \
echo "Downloading Tailwind CSS $TAILWIND_VERSION for Alpine Linux: linux-$TAILWIND_ARCH-musl" && \
curl -sSLf -o /tmp/tailwindcss "https://github.com/tailwindlabs/tailwindcss/releases/download/${TAILWIND_VERSION}/tailwindcss-linux-${TAILWIND_ARCH}-musl" && \
chmod +x /tmp/tailwindcss && \
/tmp/tailwindcss -i ./tailwind.css -o ./priv/static/app.css --minify
FROM erlang:27.1.1.0-alpine
ARG BUILD_TIMESTAMP
RUN apk add --no-cache openssl ncurses-libs curl
WORKDIR /app
COPY --from=builder /app/build/erlang-shipment /app
COPY --from=builder /app/priv ./priv
EXPOSE 8080
ENV PORT=8080
ENV BUILD_TIMESTAMP=${BUILD_TIMESTAMP}
CMD ["/app/entrypoint.sh", "run"]

View File

@@ -0,0 +1,21 @@
FROM ghcr.io/gleam-lang/gleam:v1.13.0-erlang-alpine
WORKDIR /workspace
# Install dependencies
RUN apk add --no-cache curl
# Download gleam dependencies
COPY gleam.toml manifest.toml* ./
RUN gleam deps download
# Copy source code
COPY . .
# Download and setup tailwindcss, then build CSS
RUN mkdir -p build/bin && \
curl -sLo build/bin/tailwindcss https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.17/tailwindcss-linux-x64-musl && \
chmod +x build/bin/tailwindcss && \
build/bin/tailwindcss -i ./tailwind.css -o ./priv/static/app.css
CMD ["gleam", "run"]

21
fluxer_admin/gleam.toml Normal file
View File

@@ -0,0 +1,21 @@
name = "fluxer_admin"
version = "1.0.0"
[dependencies]
gleam_stdlib = ">= 0.63.2 and < 1.0.0"
gleam_http = ">= 4.2.0 and < 5.0.0"
gleam_erlang = ">= 1.0.0 and < 2.0.0"
gleam_json = ">= 3.0.0 and < 4.0.0"
gleam_httpc = ">= 5.0.0 and < 6.0.0"
wisp = ">= 2.0.0 and < 3.0.0"
mist = ">= 5.0.0 and < 6.0.0"
lustre = ">= 5.3.0 and < 6.0.0"
dot_env = ">= 1.2.0 and < 2.0.0"
birl = ">= 1.8.0 and < 2.0.0"
logging = ">= 1.3.0 and < 2.0.0"
gleam_crypto = ">= 1.5.1 and < 2.0.0"
envoy = ">= 1.0.2 and < 2.0.0"
[dev-dependencies]
gleeunit = ">= 1.6.1 and < 2.0.0"
glailglind = ">= 2.2.0 and < 3.0.0"

34
fluxer_admin/justfile Normal file
View File

@@ -0,0 +1,34 @@
default:
@just --list
build:
gleam build
run:
just css && gleam run
test:
gleam test
css:
./build/bin/tailwindcss -i ./tailwind.css -o ./priv/static/app.css
css-watch:
./build/bin/tailwindcss -i ./tailwind.css -o ./priv/static/app.css --watch
clean:
rm -rf build/
rm -rf priv/static/app.css
deps:
gleam deps download
format:
gleam format
check: format build test
install-tailwind:
gleam run -m tailwind/install
setup: deps install-tailwind css

View File

@@ -0,0 +1,55 @@
# This file was generated by Gleam
# You typically do not need to edit this file
packages = [
{ name = "birl", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_regexp", "gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "2AC7BA26F998E3DFADDB657148BD5DDFE966958AD4D6D6957DD0D22E5B56C400" },
{ name = "directories", version = "1.2.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "D13090CFCDF6759B87217E8DDD73A75903A700148A82C1D33799F333E249BF9E" },
{ name = "dot_env", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "simplifile"], otp_app = "dot_env", source = "hex", outer_checksum = "F2B4815F1B5AF8F20A6EADBB393E715C4C35203EBD5BE8200F766EA83A0B18DE" },
{ name = "envoy", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" },
{ name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" },
{ name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" },
{ name = "glailglind", version = "2.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_httpc", "gleam_stdlib", "shellout", "simplifile", "tom"], otp_app = "glailglind", source = "hex", outer_checksum = "B0306F2C0A03A5A03633FC2BDF2D52B1E76FCAED656FB3F5EBCB7C31770E2524" },
{ name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" },
{ name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" },
{ name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" },
{ name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" },
{ name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" },
{ name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" },
{ name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" },
{ name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" },
{ name = "gleam_time", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "DCDDC040CE97DA3D2A925CDBBA08D8A78681139745754A83998641C8A3F6587E" },
{ name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" },
{ name = "gleeunit", version = "1.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "FDC68A8C492B1E9B429249062CD9BAC9B5538C6FBF584817205D0998C42E1DAC" },
{ name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" },
{ name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" },
{ name = "houdini", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "5DB1053F1AF828049C2B206D4403C18970ABEF5C18671CA3C2D2ED0DD64F6385" },
{ name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" },
{ name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" },
{ name = "lustre", version = "5.3.5", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "5CBB5DD2849D8316A2101792FC35AEB58CE4B151451044A9C2A2A70A2F7FCEB8" },
{ name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" },
{ name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" },
{ name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" },
{ name = "ranger", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_yielder"], otp_app = "ranger", source = "hex", outer_checksum = "C8988E8F8CDBD3E7F4D8F2E663EF76490390899C2B2885A6432E942495B3E854" },
{ name = "shellout", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "shellout", source = "hex", outer_checksum = "1BDC03438FEB97A6AF3E396F4ABEB32BECF20DF2452EC9A8C0ACEB7BDDF70B14" },
{ name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" },
{ name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" },
{ name = "tom", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "74D0C5A3761F7A7D06994755D4D5AD854122EF8E9F9F76A3E7547606D8C77091" },
{ name = "wisp", version = "2.1.0", build_tools = ["gleam"], requirements = ["directories", "exception", "filepath", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "houdini", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "362BDDD11BF48EB38CDE51A73BC7D1B89581B395CA998E3F23F11EC026151C54" },
]
[requirements]
birl = { version = ">= 1.8.0 and < 2.0.0" }
dot_env = { version = ">= 1.2.0 and < 2.0.0" }
glailglind = { version = ">= 2.2.0 and < 3.0.0" }
gleam_erlang = { version = ">= 1.0.0 and < 2.0.0" }
gleam_http = { version = ">= 4.2.0 and < 5.0.0" }
gleam_httpc = { version = ">= 5.0.0 and < 6.0.0" }
gleam_json = { version = ">= 3.0.0 and < 4.0.0" }
gleam_stdlib = { version = ">= 0.63.2 and < 1.0.0" }
gleeunit = { version = ">= 1.6.1 and < 2.0.0" }
logging = { version = ">= 1.3.0 and < 2.0.0" }
lustre = { version = ">= 5.3.0 and < 6.0.0" }
mist = { version = ">= 5.0.0 and < 6.0.0" }
wisp = { version = ">= 2.0.0 and < 3.0.0" }
gleam_crypto = { version = ">= 1.5.1 and < 2.0.0" }
envoy = { version = ">= 1.0.2 and < 2.0.0" }

View File

View File

@@ -0,0 +1,72 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/config
import fluxer_admin/middleware/cache_middleware
import fluxer_admin/router
import fluxer_admin/web.{type Context, Context, normalize_base_path}
import gleam/erlang/process
import mist
import wisp
import wisp/wisp_mist
pub fn main() {
wisp.configure_logger()
let assert Ok(cfg) = config.load_config()
let base_path = normalize_base_path(cfg.base_path)
let ctx =
Context(
api_endpoint: cfg.api_endpoint,
oauth_client_id: cfg.oauth_client_id,
oauth_client_secret: cfg.oauth_client_secret,
oauth_redirect_uri: cfg.oauth_redirect_uri,
secret_key_base: cfg.secret_key_base,
static_directory: "priv/static",
media_endpoint: cfg.media_endpoint,
cdn_endpoint: cfg.cdn_endpoint,
asset_version: cfg.build_timestamp,
base_path: base_path,
app_endpoint: cfg.admin_endpoint,
web_app_endpoint: cfg.web_app_endpoint,
metrics_endpoint: cfg.metrics_endpoint,
)
let assert Ok(_) =
wisp_mist.handler(handle_request(_, ctx), cfg.secret_key_base)
|> mist.new
|> mist.bind("0.0.0.0")
|> mist.port(cfg.port)
|> mist.start
process.sleep_forever()
}
fn handle_request(req: wisp.Request, ctx: Context) -> wisp.Response {
let static_dir = ctx.static_directory
case wisp.path_segments(req) {
["static", ..] -> {
use <- wisp.serve_static(req, under: "/static", from: static_dir)
router.handle_request(req, ctx)
}
_ -> router.handle_request(req, ctx)
}
|> cache_middleware.add_cache_headers
}

View File

@@ -0,0 +1,24 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/constants
import gleam/list
pub fn has_permission(admin_acls: List(String), required_acl: String) -> Bool {
list.contains(admin_acls, required_acl)
|| list.contains(admin_acls, constants.acl_wildcard)
}

View File

@@ -0,0 +1,264 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common.{
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
admin_post_with_audit,
}
import fluxer_admin/web.{type Context, type Session}
import gleam/dynamic/decode
import gleam/http
import gleam/http/request
import gleam/httpc
import gleam/json
import gleam/list
import gleam/option.{type Option}
pub type Archive {
Archive(
archive_id: String,
subject_type: String,
subject_id: String,
requested_by: String,
requested_at: String,
started_at: Option(String),
completed_at: Option(String),
failed_at: Option(String),
file_size: Option(String),
progress_percent: Int,
progress_step: Option(String),
error_message: Option(String),
download_url_expires_at: Option(String),
expires_at: Option(String),
)
}
pub type ListArchivesResponse {
ListArchivesResponse(archives: List(Archive))
}
pub fn trigger_user_archive(
ctx: Context,
session: Session,
user_id: String,
audit_log_reason: Option(String),
) -> Result(Nil, ApiError) {
admin_post_with_audit(
ctx,
session,
"/admin/archives/user",
[#("user_id", json.string(user_id))],
audit_log_reason,
)
}
pub fn trigger_guild_archive(
ctx: Context,
session: Session,
guild_id: String,
audit_log_reason: Option(String),
) -> Result(Nil, ApiError) {
admin_post_with_audit(
ctx,
session,
"/admin/archives/guild",
[#("guild_id", json.string(guild_id))],
audit_log_reason,
)
}
fn archive_decoder() {
use archive_id <- decode.field("archive_id", decode.string)
use subject_type <- decode.field("subject_type", decode.string)
use subject_id <- decode.field("subject_id", decode.string)
use requested_by <- decode.field("requested_by", decode.string)
use requested_at <- decode.field("requested_at", decode.string)
use started_at <- decode.optional_field(
"started_at",
option.None,
decode.optional(decode.string),
)
use completed_at <- decode.optional_field(
"completed_at",
option.None,
decode.optional(decode.string),
)
use failed_at <- decode.optional_field(
"failed_at",
option.None,
decode.optional(decode.string),
)
use file_size <- decode.optional_field(
"file_size",
option.None,
decode.optional(decode.string),
)
use progress_percent <- decode.field("progress_percent", decode.int)
use progress_step <- decode.optional_field(
"progress_step",
option.None,
decode.optional(decode.string),
)
use error_message <- decode.optional_field(
"error_message",
option.None,
decode.optional(decode.string),
)
use download_url_expires_at <- decode.optional_field(
"download_url_expires_at",
option.None,
decode.optional(decode.string),
)
use expires_at <- decode.optional_field(
"expires_at",
option.None,
decode.optional(decode.string),
)
decode.success(Archive(
archive_id: archive_id,
subject_type: subject_type,
subject_id: subject_id,
requested_by: requested_by,
requested_at: requested_at,
started_at: started_at,
completed_at: completed_at,
failed_at: failed_at,
file_size: file_size,
progress_percent: progress_percent,
progress_step: progress_step,
error_message: error_message,
download_url_expires_at: download_url_expires_at,
expires_at: expires_at,
))
}
pub fn list_archives(
ctx: Context,
session: Session,
subject_type: String,
subject_id: Option(String),
include_expired: Bool,
) -> Result(ListArchivesResponse, ApiError) {
let fields = [
#("subject_type", json.string(subject_type)),
#("include_expired", json.bool(include_expired)),
]
let fields = case subject_id {
option.Some(id) -> fields |> list.append([#("subject_id", json.string(id))])
option.None -> fields
}
let url = ctx.api_endpoint <> "/admin/archives/list"
let body = json.object(fields) |> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let decoder = {
use archives <- decode.field("archives", decode.list(archive_decoder()))
decode.success(ListArchivesResponse(archives: archives))
}
case json.parse(resp.body, decoder) {
Ok(result) -> Ok(result)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
pub fn get_archive_download_url(
ctx: Context,
session: Session,
subject_type: String,
subject_id: String,
archive_id: String,
) -> Result(#(String, String), ApiError) {
let url =
ctx.api_endpoint
<> "/admin/archives/"
<> subject_type
<> "/"
<> subject_id
<> "/"
<> archive_id
<> "/download"
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Get)
|> request.set_header("authorization", "Bearer " <> session.access_token)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let decoder = {
use download_url <- decode.field("downloadUrl", decode.string)
use expires_at <- decode.field("expiresAt", decode.string)
decode.success(#(download_url, expires_at))
}
case json.parse(resp.body, decoder) {
Ok(result) -> Ok(result)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}

View File

@@ -0,0 +1,128 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common.{
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
}
import fluxer_admin/web
import gleam/dynamic/decode
import gleam/http
import gleam/http/request
import gleam/httpc
import gleam/json
import gleam/option
pub type AssetPurgeResult {
AssetPurgeResult(
id: String,
asset_type: String,
found_in_db: Bool,
guild_id: option.Option(String),
)
}
pub type AssetPurgeError {
AssetPurgeError(id: String, error: String)
}
pub type AssetPurgeResponse {
AssetPurgeResponse(
processed: List(AssetPurgeResult),
errors: List(AssetPurgeError),
)
}
pub fn purge_assets(
ctx: web.Context,
session: web.Session,
ids: List(String),
audit_log_reason: option.Option(String),
) -> Result(AssetPurgeResponse, ApiError) {
let url = ctx.api_endpoint <> "/admin/assets/purge"
let body =
json.object([#("ids", json.array(ids, json.string))]) |> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
let req = case audit_log_reason {
option.Some(reason) -> request.set_header(req, "x-audit-log-reason", reason)
option.None -> req
}
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let result_decoder = {
use processed <- decode.field(
"processed",
decode.list({
use id <- decode.field("id", decode.string)
use asset_type <- decode.field("asset_type", decode.string)
use found_in_db <- decode.field("found_in_db", decode.bool)
use guild_id <- decode.field(
"guild_id",
decode.optional(decode.string),
)
decode.success(AssetPurgeResult(
id: id,
asset_type: asset_type,
found_in_db: found_in_db,
guild_id: guild_id,
))
}),
)
use errors <- decode.field(
"errors",
decode.list({
use id <- decode.field("id", decode.string)
use error <- decode.field("error", decode.string)
decode.success(AssetPurgeError(id: id, error: error))
}),
)
decode.success(AssetPurgeResponse(processed: processed, errors: errors))
}
case json.parse(resp.body, result_decoder) {
Ok(response) -> Ok(response)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}

View File

@@ -0,0 +1,169 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common.{
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
}
import fluxer_admin/web
import gleam/dict
import gleam/dynamic/decode
import gleam/http
import gleam/http/request
import gleam/httpc
import gleam/json
import gleam/option
pub type AuditLog {
AuditLog(
log_id: String,
admin_user_id: String,
target_type: String,
target_id: String,
action: String,
audit_log_reason: option.Option(String),
metadata: List(#(String, String)),
created_at: String,
)
}
pub type ListAuditLogsResponse {
ListAuditLogsResponse(logs: List(AuditLog), total: Int)
}
pub fn search_audit_logs(
ctx: web.Context,
session: web.Session,
query: option.Option(String),
admin_user_id_filter: option.Option(String),
target_type: option.Option(String),
target_id: option.Option(String),
action: option.Option(String),
limit: Int,
offset: Int,
) -> Result(ListAuditLogsResponse, ApiError) {
let url = ctx.api_endpoint <> "/admin/audit-logs/search"
let mut_fields = [#("limit", json.int(limit)), #("offset", json.int(offset))]
let mut_fields = case query {
option.Some(q) if q != "" -> [#("query", json.string(q)), ..mut_fields]
_ -> mut_fields
}
let mut_fields = case admin_user_id_filter {
option.Some(id) if id != "" -> [
#("admin_user_id", json.string(id)),
..mut_fields
]
_ -> mut_fields
}
let mut_fields = case target_type {
option.Some(tt) if tt != "" -> [
#("target_type", json.string(tt)),
..mut_fields
]
_ -> mut_fields
}
let mut_fields = case target_id {
option.Some(tid) if tid != "" -> [
#("target_id", json.string(tid)),
..mut_fields
]
_ -> mut_fields
}
let mut_fields = case action {
option.Some(act) if act != "" -> [
#("action", json.string(act)),
..mut_fields
]
_ -> mut_fields
}
let body = json.object(mut_fields) |> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let audit_log_decoder = {
use log_id <- decode.field("log_id", decode.string)
use admin_user_id <- decode.field("admin_user_id", decode.string)
use target_type_val <- decode.field("target_type", decode.string)
use target_id_val <- decode.field("target_id", decode.string)
use action <- decode.field("action", decode.string)
use audit_log_reason <- decode.field(
"audit_log_reason",
decode.optional(decode.string),
)
use metadata <- decode.field(
"metadata",
decode.dict(decode.string, decode.string),
)
use created_at <- decode.field("created_at", decode.string)
let metadata_list =
metadata
|> dict.to_list
decode.success(AuditLog(
log_id: log_id,
admin_user_id: admin_user_id,
target_type: target_type_val,
target_id: target_id_val,
action: action,
audit_log_reason: audit_log_reason,
metadata: metadata_list,
created_at: created_at,
))
}
let decoder = {
use logs <- decode.field("logs", decode.list(audit_log_decoder))
use total <- decode.field("total", decode.int)
decode.success(ListAuditLogsResponse(logs: logs, total: total))
}
case json.parse(resp.body, decoder) {
Ok(response) -> Ok(response)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}

View File

@@ -0,0 +1,249 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common.{
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
admin_post_simple, admin_post_with_audit,
}
import fluxer_admin/web
import gleam/dynamic/decode
import gleam/http
import gleam/http/request
import gleam/httpc
import gleam/json
import gleam/option
pub type CheckBanResponse {
CheckBanResponse(banned: Bool)
}
pub fn ban_email(
ctx: web.Context,
session: web.Session,
email: String,
audit_log_reason: option.Option(String),
) -> Result(Nil, ApiError) {
admin_post_with_audit(
ctx,
session,
"/admin/bans/email/add",
[#("email", json.string(email))],
audit_log_reason,
)
}
pub fn unban_email(
ctx: web.Context,
session: web.Session,
email: String,
audit_log_reason: option.Option(String),
) -> Result(Nil, ApiError) {
admin_post_with_audit(
ctx,
session,
"/admin/bans/email/remove",
[#("email", json.string(email))],
audit_log_reason,
)
}
pub fn check_email_ban(
ctx: web.Context,
session: web.Session,
email: String,
) -> Result(CheckBanResponse, ApiError) {
let url = ctx.api_endpoint <> "/admin/bans/email/check"
let body = json.object([#("email", json.string(email))]) |> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let decoder = {
use banned <- decode.field("banned", decode.bool)
decode.success(CheckBanResponse(banned: banned))
}
case json.parse(resp.body, decoder) {
Ok(response) -> Ok(response)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
pub fn ban_ip(
ctx: web.Context,
session: web.Session,
ip: String,
) -> Result(Nil, ApiError) {
admin_post_simple(ctx, session, "/admin/bans/ip/add", [
#("ip", json.string(ip)),
])
}
pub fn unban_ip(
ctx: web.Context,
session: web.Session,
ip: String,
) -> Result(Nil, ApiError) {
admin_post_simple(ctx, session, "/admin/bans/ip/remove", [
#("ip", json.string(ip)),
])
}
pub fn check_ip_ban(
ctx: web.Context,
session: web.Session,
ip: String,
) -> Result(CheckBanResponse, ApiError) {
let url = ctx.api_endpoint <> "/admin/bans/ip/check"
let body = json.object([#("ip", json.string(ip))]) |> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let decoder = {
use banned <- decode.field("banned", decode.bool)
decode.success(CheckBanResponse(banned: banned))
}
case json.parse(resp.body, decoder) {
Ok(response) -> Ok(response)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
pub fn ban_phone(
ctx: web.Context,
session: web.Session,
phone: String,
) -> Result(Nil, ApiError) {
admin_post_simple(ctx, session, "/admin/bans/phone/add", [
#("phone", json.string(phone)),
])
}
pub fn unban_phone(
ctx: web.Context,
session: web.Session,
phone: String,
) -> Result(Nil, ApiError) {
admin_post_simple(ctx, session, "/admin/bans/phone/remove", [
#("phone", json.string(phone)),
])
}
pub fn check_phone_ban(
ctx: web.Context,
session: web.Session,
phone: String,
) -> Result(CheckBanResponse, ApiError) {
let url = ctx.api_endpoint <> "/admin/bans/phone/check"
let body = json.object([#("phone", json.string(phone))]) |> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let decoder = {
use banned <- decode.field("banned", decode.bool)
decode.success(CheckBanResponse(banned: banned))
}
case json.parse(resp.body, decoder) {
Ok(response) -> Ok(response)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}

View File

@@ -0,0 +1,332 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common.{
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
}
import fluxer_admin/web
import gleam/dynamic/decode
import gleam/http
import gleam/http/request
import gleam/httpc
import gleam/json
import gleam/option
pub type BulkOperationError {
BulkOperationError(id: String, error: String)
}
pub type BulkOperationResponse {
BulkOperationResponse(
successful: List(String),
failed: List(BulkOperationError),
)
}
pub fn bulk_update_user_flags(
ctx: web.Context,
session: web.Session,
user_ids: List(String),
add_flags: List(String),
remove_flags: List(String),
audit_log_reason: option.Option(String),
) -> Result(BulkOperationResponse, ApiError) {
let url = ctx.api_endpoint <> "/admin/users/bulk-update-flags"
let body =
json.object([
#("user_ids", json.array(user_ids, json.string)),
#("add_flags", json.array(add_flags, json.string)),
#("remove_flags", json.array(remove_flags, json.string)),
])
|> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
let req = case audit_log_reason {
option.Some(reason) -> request.set_header(req, "x-audit-log-reason", reason)
option.None -> req
}
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let error_decoder = {
use id <- decode.field("id", decode.string)
use error <- decode.field("error", decode.string)
decode.success(BulkOperationError(id: id, error: error))
}
let decoder = {
use successful <- decode.field("successful", decode.list(decode.string))
use failed <- decode.field("failed", decode.list(error_decoder))
decode.success(BulkOperationResponse(
successful: successful,
failed: failed,
))
}
case json.parse(resp.body, decoder) {
Ok(response) -> Ok(response)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
pub fn bulk_update_guild_features(
ctx: web.Context,
session: web.Session,
guild_ids: List(String),
add_features: List(String),
remove_features: List(String),
audit_log_reason: option.Option(String),
) -> Result(BulkOperationResponse, ApiError) {
let url = ctx.api_endpoint <> "/admin/guilds/bulk-update-features"
let body =
json.object([
#("guild_ids", json.array(guild_ids, json.string)),
#("add_features", json.array(add_features, json.string)),
#("remove_features", json.array(remove_features, json.string)),
])
|> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
let req = case audit_log_reason {
option.Some(reason) -> request.set_header(req, "x-audit-log-reason", reason)
option.None -> req
}
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let error_decoder = {
use id <- decode.field("id", decode.string)
use error <- decode.field("error", decode.string)
decode.success(BulkOperationError(id: id, error: error))
}
let decoder = {
use successful <- decode.field("successful", decode.list(decode.string))
use failed <- decode.field("failed", decode.list(error_decoder))
decode.success(BulkOperationResponse(
successful: successful,
failed: failed,
))
}
case json.parse(resp.body, decoder) {
Ok(response) -> Ok(response)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
pub fn bulk_add_guild_members(
ctx: web.Context,
session: web.Session,
guild_id: String,
user_ids: List(String),
audit_log_reason: option.Option(String),
) -> Result(BulkOperationResponse, ApiError) {
let url = ctx.api_endpoint <> "/admin/bulk/add-guild-members"
let body =
json.object([
#("guild_id", json.string(guild_id)),
#("user_ids", json.array(user_ids, json.string)),
])
|> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
let req = case audit_log_reason {
option.Some(reason) -> request.set_header(req, "x-audit-log-reason", reason)
option.None -> req
}
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let error_decoder = {
use id <- decode.field("id", decode.string)
use error <- decode.field("error", decode.string)
decode.success(BulkOperationError(id: id, error: error))
}
let decoder = {
use successful <- decode.field("successful", decode.list(decode.string))
use failed <- decode.field("failed", decode.list(error_decoder))
decode.success(BulkOperationResponse(
successful: successful,
failed: failed,
))
}
case json.parse(resp.body, decoder) {
Ok(response) -> Ok(response)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
pub fn bulk_schedule_user_deletion(
ctx: web.Context,
session: web.Session,
user_ids: List(String),
reason_code: Int,
public_reason: option.Option(String),
days_until_deletion: Int,
audit_log_reason: option.Option(String),
) -> Result(BulkOperationResponse, ApiError) {
let url = ctx.api_endpoint <> "/admin/bulk/schedule-user-deletion"
let fields = [
#("user_ids", json.array(user_ids, json.string)),
#("reason_code", json.int(reason_code)),
#("days_until_deletion", json.int(days_until_deletion)),
]
let fields = case public_reason {
option.Some(r) -> [#("public_reason", json.string(r)), ..fields]
option.None -> fields
}
let body = json.object(fields) |> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
let req = case audit_log_reason {
option.Some(reason) -> request.set_header(req, "x-audit-log-reason", reason)
option.None -> req
}
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let error_decoder = {
use id <- decode.field("id", decode.string)
use error <- decode.field("error", decode.string)
decode.success(BulkOperationError(id: id, error: error))
}
let decoder = {
use successful <- decode.field("successful", decode.list(decode.string))
use failed <- decode.field("failed", decode.list(error_decoder))
decode.success(BulkOperationResponse(
successful: successful,
failed: failed,
))
}
case json.parse(resp.body, decoder) {
Ok(response) -> Ok(response)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}

View File

@@ -0,0 +1,124 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common.{
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
}
import fluxer_admin/web
import gleam/dynamic/decode
import gleam/http
import gleam/http/request
import gleam/httpc
import gleam/json
fn parse_codes(body: String) -> Result(List(String), ApiError) {
let decoder = {
use codes <- decode.field("codes", decode.list(decode.string))
decode.success(codes)
}
case json.parse(body, decoder) {
Ok(codes) -> Ok(codes)
Error(_) -> Error(ServerError)
}
}
pub fn generate_beta_codes(
ctx: web.Context,
session: web.Session,
count: Int,
) -> Result(List(String), ApiError) {
let url = ctx.api_endpoint <> "/admin/codes/beta"
let body = json.object([#("count", json.int(count))]) |> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) ->
case resp.status {
200 -> parse_codes(resp.body)
401 -> Error(Unauthorized)
403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
404 -> Error(NotFound)
_ -> Error(ServerError)
}
Error(_) -> Error(NetworkError)
}
}
pub fn generate_gift_codes(
ctx: web.Context,
session: web.Session,
count: Int,
product_type: String,
) -> Result(List(String), ApiError) {
let url = ctx.api_endpoint <> "/admin/codes/gift"
let body =
json.object([
#("count", json.int(count)),
#("product_type", json.string(product_type)),
])
|> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) ->
case resp.status {
200 -> parse_codes(resp.body)
401 -> Error(Unauthorized)
403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
404 -> Error(NotFound)
_ -> Error(ServerError)
}
Error(_) -> Error(NetworkError)
}
}

View File

@@ -0,0 +1,239 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/web
import gleam/dynamic/decode
import gleam/http
import gleam/http/request
import gleam/httpc
import gleam/json
import gleam/option
pub type UserLookupResult {
UserLookupResult(
id: String,
username: String,
discriminator: Int,
global_name: option.Option(String),
bot: Bool,
system: Bool,
flags: String,
avatar: option.Option(String),
banner: option.Option(String),
bio: option.Option(String),
pronouns: option.Option(String),
accent_color: option.Option(Int),
email: option.Option(String),
email_verified: Bool,
email_bounced: Bool,
phone: option.Option(String),
date_of_birth: option.Option(String),
locale: option.Option(String),
premium_type: option.Option(Int),
premium_since: option.Option(String),
premium_until: option.Option(String),
suspicious_activity_flags: Int,
temp_banned_until: option.Option(String),
pending_deletion_at: option.Option(String),
pending_bulk_message_deletion_at: option.Option(String),
deletion_reason_code: option.Option(Int),
deletion_public_reason: option.Option(String),
acls: List(String),
has_totp: Bool,
authenticator_types: List(Int),
last_active_at: option.Option(String),
last_active_ip: option.Option(String),
last_active_ip_reverse: option.Option(String),
last_active_location: option.Option(String),
)
}
pub type ApiError {
Unauthorized
Forbidden(message: String)
NotFound
ServerError
NetworkError
}
pub fn admin_post_simple(
ctx: web.Context,
session: web.Session,
path: String,
fields: List(#(String, json.Json)),
) -> Result(Nil, ApiError) {
admin_post_with_audit(ctx, session, path, fields, option.None)
}
pub fn admin_post_with_audit(
ctx: web.Context,
session: web.Session,
path: String,
fields: List(#(String, json.Json)),
audit_log_reason: option.Option(String),
) -> Result(Nil, ApiError) {
let url = ctx.api_endpoint <> path
let body = json.object(fields) |> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
let req = case audit_log_reason {
option.Some(reason) -> request.set_header(req, "x-audit-log-reason", reason)
option.None -> req
}
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> Ok(Nil)
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
pub fn user_lookup_decoder() {
use id <- decode.field("id", decode.string)
use username <- decode.field("username", decode.string)
use discriminator <- decode.field("discriminator", decode.int)
use global_name <- decode.field("global_name", decode.optional(decode.string))
use bot <- decode.field("bot", decode.bool)
use system <- decode.field("system", decode.bool)
use flags <- decode.field("flags", decode.string)
use avatar <- decode.field("avatar", decode.optional(decode.string))
use banner <- decode.field("banner", decode.optional(decode.string))
use bio <- decode.field("bio", decode.optional(decode.string))
use pronouns <- decode.field("pronouns", decode.optional(decode.string))
use accent_color <- decode.field("accent_color", decode.optional(decode.int))
use email <- decode.field("email", decode.optional(decode.string))
use email_verified <- decode.field("email_verified", decode.bool)
use email_bounced <- decode.field("email_bounced", decode.bool)
use phone <- decode.field("phone", decode.optional(decode.string))
use date_of_birth <- decode.field(
"date_of_birth",
decode.optional(decode.string),
)
use locale <- decode.field("locale", decode.optional(decode.string))
use premium_type <- decode.field("premium_type", decode.optional(decode.int))
use premium_since <- decode.field(
"premium_since",
decode.optional(decode.string),
)
use premium_until <- decode.field(
"premium_until",
decode.optional(decode.string),
)
use suspicious_activity_flags <- decode.field(
"suspicious_activity_flags",
decode.int,
)
use temp_banned_until <- decode.field(
"temp_banned_until",
decode.optional(decode.string),
)
use pending_deletion_at <- decode.field(
"pending_deletion_at",
decode.optional(decode.string),
)
use pending_bulk_message_deletion_at <- decode.field(
"pending_bulk_message_deletion_at",
decode.optional(decode.string),
)
use deletion_reason_code <- decode.field(
"deletion_reason_code",
decode.optional(decode.int),
)
use deletion_public_reason <- decode.field(
"deletion_public_reason",
decode.optional(decode.string),
)
use acls <- decode.field("acls", decode.list(decode.string))
use has_totp <- decode.field("has_totp", decode.bool)
use authenticator_types <- decode.field(
"authenticator_types",
decode.list(decode.int),
)
use last_active_at <- decode.field(
"last_active_at",
decode.optional(decode.string),
)
use last_active_ip <- decode.field(
"last_active_ip",
decode.optional(decode.string),
)
use last_active_ip_reverse <- decode.field(
"last_active_ip_reverse",
decode.optional(decode.string),
)
use last_active_location <- decode.field(
"last_active_location",
decode.optional(decode.string),
)
decode.success(UserLookupResult(
id: id,
username: username,
discriminator: discriminator,
global_name: global_name,
bot: bot,
system: system,
flags: flags,
avatar: avatar,
banner: banner,
bio: bio,
pronouns: pronouns,
accent_color: accent_color,
email: email,
email_verified: email_verified,
email_bounced: email_bounced,
phone: phone,
date_of_birth: date_of_birth,
locale: locale,
premium_type: premium_type,
premium_since: premium_since,
premium_until: premium_until,
suspicious_activity_flags: suspicious_activity_flags,
temp_banned_until: temp_banned_until,
pending_deletion_at: pending_deletion_at,
pending_bulk_message_deletion_at: pending_bulk_message_deletion_at,
deletion_reason_code: deletion_reason_code,
deletion_public_reason: deletion_public_reason,
acls: acls,
has_totp: has_totp,
authenticator_types: authenticator_types,
last_active_at: last_active_at,
last_active_ip: last_active_ip,
last_active_ip_reverse: last_active_ip_reverse,
last_active_location: last_active_location,
))
}

View File

@@ -0,0 +1,109 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common.{
type ApiError, Forbidden, NetworkError, ServerError, Unauthorized,
}
import fluxer_admin/web.{type Context, type Session}
import gleam/dict
import gleam/dynamic/decode
import gleam/http
import gleam/http/request
import gleam/httpc
import gleam/json
import gleam/list
import gleam/string
pub type FeatureFlagConfig {
FeatureFlagConfig(guild_ids: List(String))
}
pub fn get_feature_flags(
ctx: Context,
session: Session,
) -> Result(List(#(String, FeatureFlagConfig)), ApiError) {
let url = ctx.api_endpoint <> "/admin/feature-flags/get"
let body = json.object([]) |> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let decoder = {
use feature_flags <- decode.field(
"feature_flags",
decode.dict(decode.string, decode.list(decode.string)),
)
decode.success(feature_flags)
}
case json.parse(resp.body, decoder) {
Ok(flags_dict) -> {
let entries =
dict.to_list(flags_dict)
|> list.map(fn(entry) {
let #(flag, guild_ids) = entry
#(flag, FeatureFlagConfig(guild_ids:))
})
Ok(entries)
}
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> Error(Forbidden("Access denied"))
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
pub fn update_feature_flag(
ctx: Context,
session: Session,
flag_id: String,
guild_ids: List(String),
) -> Result(FeatureFlagConfig, ApiError) {
let url = ctx.api_endpoint <> "/admin/feature-flags/update"
let guild_ids_str = string.join(guild_ids, ",")
let body =
json.object([
#("flag", json.string(flag_id)),
#("guild_ids", json.string(guild_ids_str)),
])
|> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> Ok(FeatureFlagConfig(guild_ids:))
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> Error(Forbidden("Access denied"))
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}

View File

@@ -0,0 +1,182 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common.{
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
}
import fluxer_admin/web
import gleam/dynamic/decode
import gleam/http
import gleam/http/request
import gleam/httpc
import gleam/json
pub type GuildEmojiAsset {
GuildEmojiAsset(
id: String,
name: String,
animated: Bool,
creator_id: String,
media_url: String,
)
}
pub type ListGuildEmojisResponse {
ListGuildEmojisResponse(guild_id: String, emojis: List(GuildEmojiAsset))
}
pub type GuildStickerAsset {
GuildStickerAsset(
id: String,
name: String,
format_type: Int,
creator_id: String,
media_url: String,
)
}
pub type ListGuildStickersResponse {
ListGuildStickersResponse(guild_id: String, stickers: List(GuildStickerAsset))
}
pub fn list_guild_emojis(
ctx: web.Context,
session: web.Session,
guild_id: String,
) -> Result(ListGuildEmojisResponse, ApiError) {
let url = ctx.api_endpoint <> "/admin/guilds/" <> guild_id <> "/emojis"
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Get)
|> request.set_header("authorization", "Bearer " <> session.access_token)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let emoji_decoder = {
use id <- decode.field("id", decode.string)
use name <- decode.field("name", decode.string)
use animated <- decode.field("animated", decode.bool)
use creator_id <- decode.field("creator_id", decode.string)
use media_url <- decode.field("media_url", decode.string)
decode.success(GuildEmojiAsset(
id: id,
name: name,
animated: animated,
creator_id: creator_id,
media_url: media_url,
))
}
let decoder = {
use guild_id <- decode.field("guild_id", decode.string)
use emojis <- decode.field("emojis", decode.list(emoji_decoder))
decode.success(ListGuildEmojisResponse(
guild_id: guild_id,
emojis: emojis,
))
}
case json.parse(resp.body, decoder) {
Ok(result) -> Ok(result)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
pub fn list_guild_stickers(
ctx: web.Context,
session: web.Session,
guild_id: String,
) -> Result(ListGuildStickersResponse, ApiError) {
let url = ctx.api_endpoint <> "/admin/guilds/" <> guild_id <> "/stickers"
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Get)
|> request.set_header("authorization", "Bearer " <> session.access_token)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let sticker_decoder = {
use id <- decode.field("id", decode.string)
use name <- decode.field("name", decode.string)
use format_type <- decode.field("format_type", decode.int)
use creator_id <- decode.field("creator_id", decode.string)
use media_url <- decode.field("media_url", decode.string)
decode.success(GuildStickerAsset(
id: id,
name: name,
format_type: format_type,
creator_id: creator_id,
media_url: media_url,
))
}
let decoder = {
use guild_id <- decode.field("guild_id", decode.string)
use stickers <- decode.field("stickers", decode.list(sticker_decoder))
decode.success(ListGuildStickersResponse(
guild_id: guild_id,
stickers: stickers,
))
}
case json.parse(resp.body, decoder) {
Ok(result) -> Ok(result)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}

View File

@@ -0,0 +1,529 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common.{
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
admin_post_simple,
}
import fluxer_admin/web
import gleam/dynamic/decode
import gleam/http
import gleam/http/request
import gleam/httpc
import gleam/json
import gleam/option
pub type GuildChannel {
GuildChannel(
id: String,
name: String,
type_: Int,
position: Int,
parent_id: option.Option(String),
)
}
pub type GuildRole {
GuildRole(
id: String,
name: String,
color: Int,
position: Int,
permissions: String,
hoist: Bool,
mentionable: Bool,
)
}
pub type GuildMember {
GuildMember(
user: GuildMemberUser,
nick: option.Option(String),
avatar: option.Option(String),
roles: List(String),
joined_at: String,
premium_since: option.Option(String),
deaf: Bool,
mute: Bool,
flags: Int,
pending: Bool,
communication_disabled_until: option.Option(String),
)
}
pub type GuildMemberUser {
GuildMemberUser(
id: String,
username: String,
discriminator: String,
avatar: option.Option(String),
bot: Bool,
system: Bool,
public_flags: Int,
)
}
pub type ListGuildMembersResponse {
ListGuildMembersResponse(
members: List(GuildMember),
total: Int,
limit: Int,
offset: Int,
)
}
pub type GuildLookupResult {
GuildLookupResult(
id: String,
owner_id: String,
name: String,
vanity_url_code: option.Option(String),
icon: option.Option(String),
banner: option.Option(String),
splash: option.Option(String),
features: List(String),
verification_level: Int,
mfa_level: Int,
nsfw_level: Int,
explicit_content_filter: Int,
default_message_notifications: Int,
afk_channel_id: option.Option(String),
afk_timeout: Int,
system_channel_id: option.Option(String),
system_channel_flags: Int,
rules_channel_id: option.Option(String),
disabled_operations: Int,
member_count: Int,
channels: List(GuildChannel),
roles: List(GuildRole),
)
}
pub type GuildSearchResult {
GuildSearchResult(
id: String,
owner_id: String,
name: String,
features: List(String),
icon: option.Option(String),
banner: option.Option(String),
member_count: Int,
)
}
pub type SearchGuildsResponse {
SearchGuildsResponse(guilds: List(GuildSearchResult), total: Int)
}
pub fn lookup_guild(
ctx: web.Context,
session: web.Session,
guild_id: String,
) -> Result(option.Option(GuildLookupResult), ApiError) {
let url = ctx.api_endpoint <> "/admin/guilds/lookup"
let body =
json.object([#("guild_id", json.string(guild_id))]) |> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let channel_decoder = {
use id <- decode.field("id", decode.string)
use name <- decode.field("name", decode.string)
use type_ <- decode.field("type", decode.int)
use position <- decode.field("position", decode.int)
use parent_id <- decode.field(
"parent_id",
decode.optional(decode.string),
)
decode.success(GuildChannel(
id: id,
name: name,
type_: type_,
position: position,
parent_id: parent_id,
))
}
let role_decoder = {
use id <- decode.field("id", decode.string)
use name <- decode.field("name", decode.string)
use color <- decode.field("color", decode.int)
use position <- decode.field("position", decode.int)
use permissions <- decode.field("permissions", decode.string)
use hoist <- decode.field("hoist", decode.bool)
use mentionable <- decode.field("mentionable", decode.bool)
decode.success(GuildRole(
id: id,
name: name,
color: color,
position: position,
permissions: permissions,
hoist: hoist,
mentionable: mentionable,
))
}
let guild_decoder = {
use id <- decode.field("id", decode.string)
use owner_id <- decode.field("owner_id", decode.string)
use name <- decode.field("name", decode.string)
use vanity_url_code <- decode.field(
"vanity_url_code",
decode.optional(decode.string),
)
use icon <- decode.field("icon", decode.optional(decode.string))
use banner <- decode.field("banner", decode.optional(decode.string))
use splash <- decode.field("splash", decode.optional(decode.string))
use features <- decode.field("features", decode.list(decode.string))
use verification_level <- decode.field("verification_level", decode.int)
use mfa_level <- decode.field("mfa_level", decode.int)
use nsfw_level <- decode.field("nsfw_level", decode.int)
use explicit_content_filter <- decode.field(
"explicit_content_filter",
decode.int,
)
use default_message_notifications <- decode.field(
"default_message_notifications",
decode.int,
)
use afk_channel_id <- decode.field(
"afk_channel_id",
decode.optional(decode.string),
)
use afk_timeout <- decode.field("afk_timeout", decode.int)
use system_channel_id <- decode.field(
"system_channel_id",
decode.optional(decode.string),
)
use system_channel_flags <- decode.field(
"system_channel_flags",
decode.int,
)
use rules_channel_id <- decode.field(
"rules_channel_id",
decode.optional(decode.string),
)
use disabled_operations <- decode.field(
"disabled_operations",
decode.int,
)
use member_count <- decode.field("member_count", decode.int)
use channels <- decode.field("channels", decode.list(channel_decoder))
use roles <- decode.field("roles", decode.list(role_decoder))
decode.success(GuildLookupResult(
id: id,
owner_id: owner_id,
name: name,
vanity_url_code: vanity_url_code,
icon: icon,
banner: banner,
splash: splash,
features: features,
verification_level: verification_level,
mfa_level: mfa_level,
nsfw_level: nsfw_level,
explicit_content_filter: explicit_content_filter,
default_message_notifications: default_message_notifications,
afk_channel_id: afk_channel_id,
afk_timeout: afk_timeout,
system_channel_id: system_channel_id,
system_channel_flags: system_channel_flags,
rules_channel_id: rules_channel_id,
disabled_operations: disabled_operations,
member_count: member_count,
channels: channels,
roles: roles,
))
}
let decoder = {
use guild <- decode.field("guild", decode.optional(guild_decoder))
decode.success(guild)
}
case json.parse(resp.body, decoder) {
Ok(result) -> Ok(result)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
pub fn clear_guild_fields(
ctx: web.Context,
session: web.Session,
guild_id: String,
fields: List(String),
) -> Result(Nil, ApiError) {
admin_post_simple(ctx, session, "/admin/guilds/clear-fields", [
#("guild_id", json.string(guild_id)),
#("fields", json.array(fields, json.string)),
])
}
pub fn update_guild_features(
ctx: web.Context,
session: web.Session,
guild_id: String,
add_features: List(String),
remove_features: List(String),
) -> Result(Nil, ApiError) {
admin_post_simple(ctx, session, "/admin/guilds/update-features", [
#("guild_id", json.string(guild_id)),
#("add_features", json.array(add_features, json.string)),
#("remove_features", json.array(remove_features, json.string)),
])
}
pub fn update_guild_settings(
ctx: web.Context,
session: web.Session,
guild_id: String,
verification_level: option.Option(Int),
mfa_level: option.Option(Int),
nsfw_level: option.Option(Int),
explicit_content_filter: option.Option(Int),
default_message_notifications: option.Option(Int),
disabled_operations: option.Option(Int),
) -> Result(Nil, ApiError) {
let mut_fields = [#("guild_id", json.string(guild_id))]
let mut_fields = case verification_level {
option.Some(vl) -> [#("verification_level", json.int(vl)), ..mut_fields]
option.None -> mut_fields
}
let mut_fields = case mfa_level {
option.Some(ml) -> [#("mfa_level", json.int(ml)), ..mut_fields]
option.None -> mut_fields
}
let mut_fields = case nsfw_level {
option.Some(nl) -> [#("nsfw_level", json.int(nl)), ..mut_fields]
option.None -> mut_fields
}
let mut_fields = case explicit_content_filter {
option.Some(ecf) -> [
#("explicit_content_filter", json.int(ecf)),
..mut_fields
]
option.None -> mut_fields
}
let mut_fields = case default_message_notifications {
option.Some(dmn) -> [
#("default_message_notifications", json.int(dmn)),
..mut_fields
]
option.None -> mut_fields
}
let mut_fields = case disabled_operations {
option.Some(dops) -> [
#("disabled_operations", json.int(dops)),
..mut_fields
]
option.None -> mut_fields
}
admin_post_simple(ctx, session, "/admin/guilds/update-settings", mut_fields)
}
pub fn update_guild_name(
ctx: web.Context,
session: web.Session,
guild_id: String,
name: String,
) -> Result(Nil, ApiError) {
admin_post_simple(ctx, session, "/admin/guilds/update-name", [
#("guild_id", json.string(guild_id)),
#("name", json.string(name)),
])
}
pub fn update_guild_vanity(
ctx: web.Context,
session: web.Session,
guild_id: String,
vanity_url_code: option.Option(String),
) -> Result(Nil, ApiError) {
let fields = [#("guild_id", json.string(guild_id))]
let fields = case vanity_url_code {
option.Some(code) -> [#("vanity_url_code", json.string(code)), ..fields]
option.None -> fields
}
admin_post_simple(ctx, session, "/admin/guilds/update-vanity", fields)
}
pub fn transfer_guild_ownership(
ctx: web.Context,
session: web.Session,
guild_id: String,
new_owner_id: String,
) -> Result(Nil, ApiError) {
admin_post_simple(ctx, session, "/admin/guilds/transfer-ownership", [
#("guild_id", json.string(guild_id)),
#("new_owner_id", json.string(new_owner_id)),
])
}
pub fn reload_guild(
ctx: web.Context,
session: web.Session,
guild_id: String,
) -> Result(Nil, ApiError) {
admin_post_simple(ctx, session, "/admin/guilds/reload", [
#("guild_id", json.string(guild_id)),
])
}
pub fn shutdown_guild(
ctx: web.Context,
session: web.Session,
guild_id: String,
) -> Result(Nil, ApiError) {
admin_post_simple(ctx, session, "/admin/guilds/shutdown", [
#("guild_id", json.string(guild_id)),
])
}
pub fn delete_guild(
ctx: web.Context,
session: web.Session,
guild_id: String,
) -> Result(Nil, ApiError) {
admin_post_simple(ctx, session, "/admin/guilds/delete", [
#("guild_id", json.string(guild_id)),
])
}
pub fn force_add_user_to_guild(
ctx: web.Context,
session: web.Session,
user_id: String,
guild_id: String,
) -> Result(Nil, ApiError) {
admin_post_simple(ctx, session, "/admin/guilds/force-add-user", [
#("user_id", json.string(user_id)),
#("guild_id", json.string(guild_id)),
])
}
pub fn search_guilds(
ctx: web.Context,
session: web.Session,
query: String,
limit: Int,
offset: Int,
) -> Result(SearchGuildsResponse, ApiError) {
let url = ctx.api_endpoint <> "/admin/guilds/search"
let body =
json.object([
#("query", json.string(query)),
#("limit", json.int(limit)),
#("offset", json.int(offset)),
])
|> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let guild_decoder = {
use id <- decode.field("id", decode.string)
use owner_id <- decode.optional_field("owner_id", "", decode.string)
use name <- decode.field("name", decode.string)
use features <- decode.field("features", decode.list(decode.string))
use icon <- decode.optional_field(
"icon",
option.None,
decode.optional(decode.string),
)
use banner <- decode.optional_field(
"banner",
option.None,
decode.optional(decode.string),
)
use member_count <- decode.optional_field("member_count", 0, decode.int)
decode.success(GuildSearchResult(
id: id,
owner_id: owner_id,
name: name,
features: features,
icon: icon,
banner: banner,
member_count: member_count,
))
}
let decoder = {
use guilds <- decode.field("guilds", decode.list(guild_decoder))
use total <- decode.field("total", decode.int)
decode.success(SearchGuildsResponse(guilds: guilds, total: total))
}
case json.parse(resp.body, decoder) {
Ok(result) -> Ok(result)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}

View File

@@ -0,0 +1,191 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common.{
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
}
import fluxer_admin/web
import gleam/dynamic/decode
import gleam/http
import gleam/http/request
import gleam/httpc
import gleam/json
import gleam/option
pub type GuildMember {
GuildMember(
user: GuildMemberUser,
nick: option.Option(String),
avatar: option.Option(String),
roles: List(String),
joined_at: String,
premium_since: option.Option(String),
deaf: Bool,
mute: Bool,
flags: Int,
pending: Bool,
communication_disabled_until: option.Option(String),
)
}
pub type GuildMemberUser {
GuildMemberUser(
id: String,
username: String,
discriminator: String,
avatar: option.Option(String),
bot: Bool,
system: Bool,
public_flags: Int,
)
}
pub type ListGuildMembersResponse {
ListGuildMembersResponse(
members: List(GuildMember),
total: Int,
limit: Int,
offset: Int,
)
}
pub fn list_guild_members(
ctx: web.Context,
session: web.Session,
guild_id: String,
limit: Int,
offset: Int,
) -> Result(ListGuildMembersResponse, ApiError) {
let url = ctx.api_endpoint <> "/admin/guilds/list-members"
let body =
json.object([
#("guild_id", json.string(guild_id)),
#("limit", json.int(limit)),
#("offset", json.int(offset)),
])
|> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let user_decoder = {
use id <- decode.field("id", decode.string)
use username <- decode.field("username", decode.string)
use discriminator <- decode.field("discriminator", decode.string)
use avatar <- decode.field("avatar", decode.optional(decode.string))
use bot <- decode.optional_field("bot", False, decode.bool)
use system <- decode.optional_field("system", False, decode.bool)
use public_flags <- decode.optional_field("public_flags", 0, decode.int)
decode.success(GuildMemberUser(
id: id,
username: username,
discriminator: discriminator,
avatar: avatar,
bot: bot,
system: system,
public_flags: public_flags,
))
}
let member_decoder = {
use user <- decode.field("user", user_decoder)
use nick <- decode.optional_field(
"nick",
option.None,
decode.optional(decode.string),
)
use avatar <- decode.optional_field(
"avatar",
option.None,
decode.optional(decode.string),
)
use roles <- decode.field("roles", decode.list(decode.string))
use joined_at <- decode.field("joined_at", decode.string)
use premium_since <- decode.optional_field(
"premium_since",
option.None,
decode.optional(decode.string),
)
use deaf <- decode.optional_field("deaf", False, decode.bool)
use mute <- decode.optional_field("mute", False, decode.bool)
use flags <- decode.optional_field("flags", 0, decode.int)
use pending <- decode.optional_field("pending", False, decode.bool)
use communication_disabled_until <- decode.optional_field(
"communication_disabled_until",
option.None,
decode.optional(decode.string),
)
decode.success(GuildMember(
user: user,
nick: nick,
avatar: avatar,
roles: roles,
joined_at: joined_at,
premium_since: premium_since,
deaf: deaf,
mute: mute,
flags: flags,
pending: pending,
communication_disabled_until: communication_disabled_until,
))
}
let decoder = {
use members <- decode.field("members", decode.list(member_decoder))
use total <- decode.field("total", decode.int)
use limit <- decode.field("limit", decode.int)
use offset <- decode.field("offset", decode.int)
decode.success(ListGuildMembersResponse(
members: members,
total: total,
limit: limit,
offset: offset,
))
}
case json.parse(resp.body, decoder) {
Ok(result) -> Ok(result)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}

View File

@@ -0,0 +1,172 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common.{
type ApiError, Forbidden, NetworkError, ServerError, Unauthorized,
}
import fluxer_admin/web.{type Context, type Session}
import gleam/dynamic/decode
import gleam/http
import gleam/http/request
import gleam/httpc
import gleam/json
import gleam/option
pub type InstanceConfig {
InstanceConfig(
manual_review_enabled: Bool,
manual_review_schedule_enabled: Bool,
manual_review_schedule_start_hour_utc: Int,
manual_review_schedule_end_hour_utc: Int,
manual_review_active_now: Bool,
registration_alerts_webhook_url: String,
system_alerts_webhook_url: String,
)
}
fn instance_config_decoder() {
use manual_review_enabled <- decode.field(
"manual_review_enabled",
decode.bool,
)
use manual_review_schedule_enabled <- decode.field(
"manual_review_schedule_enabled",
decode.bool,
)
use manual_review_schedule_start_hour_utc <- decode.field(
"manual_review_schedule_start_hour_utc",
decode.int,
)
use manual_review_schedule_end_hour_utc <- decode.field(
"manual_review_schedule_end_hour_utc",
decode.int,
)
use manual_review_active_now <- decode.field(
"manual_review_active_now",
decode.bool,
)
use registration_alerts_webhook_url <- decode.field(
"registration_alerts_webhook_url",
decode.optional(decode.string),
)
use system_alerts_webhook_url <- decode.field(
"system_alerts_webhook_url",
decode.optional(decode.string),
)
decode.success(InstanceConfig(
manual_review_enabled:,
manual_review_schedule_enabled:,
manual_review_schedule_start_hour_utc:,
manual_review_schedule_end_hour_utc:,
manual_review_active_now:,
registration_alerts_webhook_url: option.unwrap(
registration_alerts_webhook_url,
"",
),
system_alerts_webhook_url: option.unwrap(system_alerts_webhook_url, ""),
))
}
pub fn get_instance_config(
ctx: Context,
session: Session,
) -> Result(InstanceConfig, ApiError) {
let url = ctx.api_endpoint <> "/admin/instance-config/get"
let body = json.object([]) |> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
case json.parse(resp.body, instance_config_decoder()) {
Ok(config) -> Ok(config)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> Error(Forbidden("Access denied"))
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
pub fn update_instance_config(
ctx: Context,
session: Session,
manual_review_enabled: Bool,
manual_review_schedule_enabled: Bool,
manual_review_schedule_start_hour_utc: Int,
manual_review_schedule_end_hour_utc: Int,
registration_alerts_webhook_url: String,
system_alerts_webhook_url: String,
) -> Result(InstanceConfig, ApiError) {
let url = ctx.api_endpoint <> "/admin/instance-config/update"
let registration_webhook_json = case registration_alerts_webhook_url {
"" -> json.null()
url -> json.string(url)
}
let system_webhook_json = case system_alerts_webhook_url {
"" -> json.null()
url -> json.string(url)
}
let body =
json.object([
#("manual_review_enabled", json.bool(manual_review_enabled)),
#(
"manual_review_schedule_enabled",
json.bool(manual_review_schedule_enabled),
),
#(
"manual_review_schedule_start_hour_utc",
json.int(manual_review_schedule_start_hour_utc),
),
#(
"manual_review_schedule_end_hour_utc",
json.int(manual_review_schedule_end_hour_utc),
),
#("registration_alerts_webhook_url", registration_webhook_json),
#("system_alerts_webhook_url", system_webhook_json),
])
|> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
case json.parse(resp.body, instance_config_decoder()) {
Ok(config) -> Ok(config)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> Error(Forbidden("Access denied"))
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}

View File

@@ -0,0 +1,508 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common.{
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
admin_post_with_audit,
}
import fluxer_admin/web
import gleam/dynamic/decode
import gleam/http
import gleam/http/request
import gleam/httpc
import gleam/json
import gleam/option
pub type MessageAttachment {
MessageAttachment(filename: String, url: String)
}
pub type Message {
Message(
id: String,
channel_id: String,
author_id: String,
author_username: String,
content: String,
timestamp: String,
attachments: List(MessageAttachment),
)
}
pub type LookupMessageResponse {
LookupMessageResponse(messages: List(Message), message_id: String)
}
pub type MessageShredResponse {
MessageShredResponse(job_id: String, requested: option.Option(Int))
}
pub type DeleteAllUserMessagesResponse {
DeleteAllUserMessagesResponse(
dry_run: Bool,
channel_count: Int,
message_count: Int,
job_id: option.Option(String),
)
}
pub type MessageShredStatus {
MessageShredStatus(
status: String,
requested: option.Option(Int),
total: option.Option(Int),
processed: option.Option(Int),
skipped: option.Option(Int),
started_at: option.Option(String),
completed_at: option.Option(String),
failed_at: option.Option(String),
error: option.Option(String),
)
}
pub fn delete_message(
ctx: web.Context,
session: web.Session,
channel_id: String,
message_id: String,
audit_log_reason: option.Option(String),
) -> Result(Nil, ApiError) {
let fields = [
#("channel_id", json.string(channel_id)),
#("message_id", json.string(message_id)),
]
admin_post_with_audit(
ctx,
session,
"/admin/messages/delete",
fields,
audit_log_reason,
)
}
pub fn lookup_message(
ctx: web.Context,
session: web.Session,
channel_id: String,
message_id: String,
context_limit: Int,
) -> Result(LookupMessageResponse, ApiError) {
let url = ctx.api_endpoint <> "/admin/messages/lookup"
let body =
json.object([
#("channel_id", json.string(channel_id)),
#("message_id", json.string(message_id)),
#("context_limit", json.int(context_limit)),
])
|> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let attachment_decoder = {
use filename <- decode.field("filename", decode.string)
use url <- decode.field("url", decode.string)
decode.success(MessageAttachment(filename: filename, url: url))
}
let message_decoder = {
use id <- decode.field("id", decode.string)
use channel_id <- decode.field("channel_id", 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 attachments <- decode.optional_field(
"attachments",
[],
decode.list(attachment_decoder),
)
decode.success(Message(
id: id,
channel_id: channel_id,
author_id: author_id,
author_username: author_username,
content: content,
timestamp: timestamp,
attachments: attachments,
))
}
let decoder = {
use messages <- decode.field("messages", decode.list(message_decoder))
use message_id <- decode.field("message_id", decode.string)
decode.success(LookupMessageResponse(
messages: messages,
message_id: message_id,
))
}
case json.parse(resp.body, decoder) {
Ok(result) -> Ok(result)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
pub fn queue_message_shred(
ctx: web.Context,
session: web.Session,
user_id: String,
entries: json.Json,
) -> Result(MessageShredResponse, ApiError) {
let url = ctx.api_endpoint <> "/admin/messages/shred"
let body =
json.object([
#("user_id", json.string(user_id)),
#("entries", entries),
])
|> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let decoder = {
use job_id <- decode.field("job_id", decode.string)
use requested <- decode.optional_field(
"requested",
option.None,
decode.optional(decode.int),
)
decode.success(MessageShredResponse(
job_id: job_id,
requested: requested,
))
}
case json.parse(resp.body, decoder) {
Ok(result) -> Ok(result)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
pub fn delete_all_user_messages(
ctx: web.Context,
session: web.Session,
user_id: String,
dry_run: Bool,
) -> Result(DeleteAllUserMessagesResponse, ApiError) {
let url = ctx.api_endpoint <> "/admin/messages/delete-all"
let body =
json.object([
#("user_id", json.string(user_id)),
#("dry_run", json.bool(dry_run)),
])
|> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let decoder = {
use dry_run <- decode.field("dry_run", decode.bool)
use channel_count <- decode.field("channel_count", decode.int)
use message_count <- decode.field("message_count", decode.int)
use job_id <- decode.optional_field(
"job_id",
option.None,
decode.optional(decode.string),
)
decode.success(DeleteAllUserMessagesResponse(
dry_run: dry_run,
channel_count: channel_count,
message_count: message_count,
job_id: job_id,
))
}
case json.parse(resp.body, decoder) {
Ok(result) -> Ok(result)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
pub fn get_message_shred_status(
ctx: web.Context,
session: web.Session,
job_id: String,
) -> Result(MessageShredStatus, ApiError) {
let url = ctx.api_endpoint <> "/admin/messages/shred-status"
let body =
json.object([#("job_id", json.string(job_id))])
|> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let decoder = {
use status <- decode.field("status", decode.string)
use requested <- decode.optional_field(
"requested",
option.None,
decode.optional(decode.int),
)
use total <- decode.optional_field(
"total",
option.None,
decode.optional(decode.int),
)
use processed <- decode.optional_field(
"processed",
option.None,
decode.optional(decode.int),
)
use skipped <- decode.optional_field(
"skipped",
option.None,
decode.optional(decode.int),
)
use started_at <- decode.optional_field(
"started_at",
option.None,
decode.optional(decode.string),
)
use completed_at <- decode.optional_field(
"completed_at",
option.None,
decode.optional(decode.string),
)
use failed_at <- decode.optional_field(
"failed_at",
option.None,
decode.optional(decode.string),
)
use error <- decode.optional_field(
"error",
option.None,
decode.optional(decode.string),
)
decode.success(MessageShredStatus(
status: status,
requested: requested,
total: total,
processed: processed,
skipped: skipped,
started_at: started_at,
completed_at: completed_at,
failed_at: failed_at,
error: error,
))
}
case json.parse(resp.body, decoder) {
Ok(result) -> Ok(result)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
pub fn lookup_message_by_attachment(
ctx: web.Context,
session: web.Session,
channel_id: String,
attachment_id: String,
filename: String,
context_limit: Int,
) -> Result(LookupMessageResponse, ApiError) {
let url = ctx.api_endpoint <> "/admin/messages/lookup-by-attachment"
let body =
json.object([
#("channel_id", json.string(channel_id)),
#("attachment_id", json.string(attachment_id)),
#("filename", json.string(filename)),
#("context_limit", json.int(context_limit)),
])
|> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let attachment_decoder = {
use filename <- decode.field("filename", decode.string)
use url <- decode.field("url", decode.string)
decode.success(MessageAttachment(filename: filename, url: url))
}
let message_decoder = {
use id <- decode.field("id", decode.string)
use channel_id <- decode.field("channel_id", 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 attachments <- decode.optional_field(
"attachments",
[],
decode.list(attachment_decoder),
)
decode.success(Message(
id: id,
channel_id: channel_id,
author_id: author_id,
author_username: author_username,
content: content,
timestamp: timestamp,
attachments: attachments,
))
}
let decoder = {
use messages <- decode.field("messages", decode.list(message_decoder))
use message_id <- decode.field("message_id", decode.string)
decode.success(LookupMessageResponse(
messages: messages,
message_id: message_id,
))
}
case json.parse(resp.body, decoder) {
Ok(result) -> Ok(result)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}

View File

@@ -0,0 +1,264 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common.{
type ApiError, NetworkError, NotFound, ServerError,
}
import fluxer_admin/web.{type Context}
import gleam/dynamic/decode
import gleam/http
import gleam/http/request
import gleam/httpc
import gleam/int
import gleam/json
import gleam/option.{type Option, None, Some}
pub type DataPoint {
DataPoint(timestamp: Int, value: Float)
}
pub type QueryResponse {
QueryResponse(metric: String, data: List(DataPoint))
}
pub type TopEntry {
TopEntry(label: String, value: Float)
}
pub type AggregateResponse {
AggregateResponse(
metric: String,
total: Float,
breakdown: option.Option(List(TopEntry)),
)
}
pub type TopQueryResponse {
TopQueryResponse(metric: String, entries: List(TopEntry))
}
pub type CrashEvent {
CrashEvent(
id: String,
timestamp: Int,
guild_id: String,
stacktrace: String,
notified: Bool,
)
}
pub type CrashesResponse {
CrashesResponse(crashes: List(CrashEvent))
}
pub fn query_metrics(
ctx: Context,
metric: String,
start: Option(String),
end: Option(String),
) -> Result(QueryResponse, ApiError) {
case ctx.metrics_endpoint {
None -> Error(NotFound)
Some(endpoint) -> {
let query_params = case start, end {
Some(s), Some(e) ->
"?metric=" <> metric <> "&start=" <> s <> "&end=" <> e
Some(s), None -> "?metric=" <> metric <> "&start=" <> s
None, Some(e) -> "?metric=" <> metric <> "&end=" <> e
None, None -> "?metric=" <> metric
}
let url = endpoint <> "/query" <> query_params
let assert Ok(req) = request.to(url)
let req = req |> request.set_method(http.Get)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let data_point_decoder = {
use timestamp <- decode.field("timestamp", decode.int)
use value <- decode.field("value", decode.float)
decode.success(DataPoint(timestamp: timestamp, value: value))
}
let decoder = {
use metric_name <- decode.field("metric", decode.string)
use data <- decode.field("data", decode.list(data_point_decoder))
decode.success(QueryResponse(metric: metric_name, data: data))
}
case json.parse(resp.body, decoder) {
Ok(result) -> Ok(result)
Error(_) -> Error(ServerError)
}
}
Ok(_) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
}
}
pub fn query_aggregate(
ctx: Context,
metric: String,
) -> Result(AggregateResponse, ApiError) {
query_aggregate_grouped(ctx, metric, option.None)
}
fn top_entry_decoder() -> decode.Decoder(TopEntry) {
{
use label <- decode.field("label", decode.string)
use value <- decode.field("value", decode.float)
decode.success(TopEntry(label: label, value: value))
}
}
pub fn query_aggregate_grouped(
ctx: Context,
metric: String,
group_by: option.Option(String),
) -> Result(AggregateResponse, ApiError) {
case ctx.metrics_endpoint {
None -> Error(NotFound)
Some(endpoint) -> {
let query_params = case group_by {
option.Some(group) -> "?metric=" <> metric <> "&group_by=" <> group
option.None -> "?metric=" <> metric
}
let url = endpoint <> "/query/aggregate" <> query_params
let assert Ok(req) = request.to(url)
let req = req |> request.set_method(http.Get)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let decoder = {
use metric_name <- decode.field("metric", decode.string)
use total <- decode.field("total", decode.float)
use breakdown <- decode.optional_field(
"breakdown",
option.None,
decode.list(top_entry_decoder()) |> decode.map(option.Some),
)
decode.success(AggregateResponse(
metric: metric_name,
total: total,
breakdown: breakdown,
))
}
case json.parse(resp.body, decoder) {
Ok(result) -> Ok(result)
Error(_) -> Error(ServerError)
}
}
Ok(_) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
}
}
pub fn query_top(
ctx: Context,
metric: String,
limit: Int,
) -> Result(TopQueryResponse, ApiError) {
case ctx.metrics_endpoint {
None -> Error(NotFound)
Some(endpoint) -> {
let url =
endpoint
<> "/query/top?metric="
<> metric
<> "&limit="
<> int.to_string(limit)
let assert Ok(req) = request.to(url)
let req = req |> request.set_method(http.Get)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let decoder = {
use metric_name <- decode.field("metric", decode.string)
use entries <- decode.field(
"entries",
decode.list(top_entry_decoder()),
)
decode.success(TopQueryResponse(
metric: metric_name,
entries: entries,
))
}
case json.parse(resp.body, decoder) {
Ok(result) -> Ok(result)
Error(_) -> Error(ServerError)
}
}
Ok(_) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
}
}
pub fn query_crashes(
ctx: Context,
limit: Int,
) -> Result(CrashesResponse, ApiError) {
case ctx.metrics_endpoint {
None -> Error(NotFound)
Some(endpoint) -> {
let url = endpoint <> "/query/crashes?limit=" <> int.to_string(limit)
let assert Ok(req) = request.to(url)
let req = req |> request.set_method(http.Get)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let crash_decoder = {
use id <- decode.field("id", decode.string)
use timestamp <- decode.field("timestamp", decode.int)
use guild_id <- decode.field("guild_id", decode.string)
use stacktrace <- decode.field("stacktrace", decode.string)
use notified <- decode.field("notified", decode.bool)
decode.success(CrashEvent(
id: id,
timestamp: timestamp,
guild_id: guild_id,
stacktrace: stacktrace,
notified: notified,
))
}
let decoder = {
use crashes <- decode.field("crashes", decode.list(crash_decoder))
decode.success(CrashesResponse(crashes: crashes))
}
case json.parse(resp.body, decoder) {
Ok(result) -> Ok(result)
Error(_) -> Error(ServerError)
}
}
Ok(_) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,705 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common.{
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
admin_post_with_audit,
}
import fluxer_admin/api/messages.{type Message, Message, MessageAttachment}
import fluxer_admin/web
import gleam/dynamic/decode
import gleam/http
import gleam/http/request
import gleam/httpc
import gleam/json
import gleam/option
pub type Report {
Report(
report_id: String,
reporter_id: option.Option(String),
reporter_tag: option.Option(String),
reporter_username: option.Option(String),
reporter_discriminator: option.Option(String),
reporter_email: option.Option(String),
reporter_full_legal_name: option.Option(String),
reporter_country_of_residence: option.Option(String),
reported_at: String,
status: Int,
report_type: Int,
category: String,
additional_info: option.Option(String),
reported_user_id: option.Option(String),
reported_user_tag: option.Option(String),
reported_user_username: option.Option(String),
reported_user_discriminator: option.Option(String),
reported_user_avatar_hash: option.Option(String),
reported_guild_id: option.Option(String),
reported_guild_name: option.Option(String),
reported_guild_icon_hash: option.Option(String),
reported_message_id: option.Option(String),
reported_channel_id: option.Option(String),
reported_channel_name: option.Option(String),
reported_guild_invite_code: option.Option(String),
resolved_at: option.Option(String),
resolved_by_admin_id: option.Option(String),
public_comment: option.Option(String),
message_context: List(Message),
)
}
pub type ListReportsResponse {
ListReportsResponse(reports: List(Report))
}
pub type SearchReportResult {
SearchReportResult(
report_id: String,
reporter_id: option.Option(String),
reporter_tag: option.Option(String),
reporter_username: option.Option(String),
reporter_discriminator: option.Option(String),
reporter_email: option.Option(String),
reporter_full_legal_name: option.Option(String),
reporter_country_of_residence: option.Option(String),
reported_at: String,
status: Int,
report_type: Int,
category: String,
additional_info: option.Option(String),
reported_user_id: option.Option(String),
reported_user_tag: option.Option(String),
reported_user_username: option.Option(String),
reported_user_discriminator: option.Option(String),
reported_user_avatar_hash: option.Option(String),
reported_guild_id: option.Option(String),
reported_guild_name: option.Option(String),
reported_guild_invite_code: option.Option(String),
)
}
pub type SearchReportsResponse {
SearchReportsResponse(
reports: List(SearchReportResult),
total: Int,
offset: Int,
limit: Int,
)
}
pub fn list_reports(
ctx: web.Context,
session: web.Session,
status: Int,
limit: Int,
offset: option.Option(Int),
) -> Result(ListReportsResponse, ApiError) {
let url = ctx.api_endpoint <> "/admin/reports/list"
let mut_fields = [#("status", json.int(status)), #("limit", json.int(limit))]
let mut_fields = case offset {
option.Some(o) -> [#("offset", json.int(o)), ..mut_fields]
option.None -> mut_fields
}
let body = json.object(mut_fields) |> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let report_decoder = {
use report_id <- decode.field("report_id", decode.string)
use reporter_id <- decode.field(
"reporter_id",
decode.optional(decode.string),
)
use reporter_tag <- decode.field(
"reporter_tag",
decode.optional(decode.string),
)
use reporter_username <- decode.field(
"reporter_username",
decode.optional(decode.string),
)
use reporter_discriminator <- decode.field(
"reporter_discriminator",
decode.optional(decode.string),
)
use reporter_email <- decode.field(
"reporter_email",
decode.optional(decode.string),
)
use reporter_full_legal_name <- decode.field(
"reporter_full_legal_name",
decode.optional(decode.string),
)
use reporter_country_of_residence <- decode.field(
"reporter_country_of_residence",
decode.optional(decode.string),
)
use reported_at <- decode.field("reported_at", decode.string)
use status_val <- decode.field("status", decode.int)
use report_type <- decode.field("report_type", decode.int)
use category <- decode.field("category", decode.string)
use additional_info <- decode.field(
"additional_info",
decode.optional(decode.string),
)
use reported_user_id <- decode.field(
"reported_user_id",
decode.optional(decode.string),
)
use reported_user_tag <- decode.field(
"reported_user_tag",
decode.optional(decode.string),
)
use reported_user_username <- decode.field(
"reported_user_username",
decode.optional(decode.string),
)
use reported_user_discriminator <- decode.field(
"reported_user_discriminator",
decode.optional(decode.string),
)
use reported_user_avatar_hash <- decode.field(
"reported_user_avatar_hash",
decode.optional(decode.string),
)
use reported_guild_id <- decode.field(
"reported_guild_id",
decode.optional(decode.string),
)
use reported_guild_name <- decode.field(
"reported_guild_name",
decode.optional(decode.string),
)
use reported_guild_icon_hash <- decode.field(
"reported_guild_icon_hash",
decode.optional(decode.string),
)
use reported_guild_invite_code <- decode.field(
"reported_guild_invite_code",
decode.optional(decode.string),
)
use reported_message_id <- decode.field(
"reported_message_id",
decode.optional(decode.string),
)
use reported_channel_id <- decode.field(
"reported_channel_id",
decode.optional(decode.string),
)
use reported_channel_name <- decode.field(
"reported_channel_name",
decode.optional(decode.string),
)
use resolved_at <- decode.field(
"resolved_at",
decode.optional(decode.string),
)
use resolved_by_admin_id <- decode.field(
"resolved_by_admin_id",
decode.optional(decode.string),
)
use public_comment <- decode.field(
"public_comment",
decode.optional(decode.string),
)
decode.success(
Report(
report_id: report_id,
reporter_id: reporter_id,
reporter_tag: reporter_tag,
reporter_username: reporter_username,
reporter_discriminator: reporter_discriminator,
reporter_email: reporter_email,
reporter_full_legal_name: reporter_full_legal_name,
reporter_country_of_residence: reporter_country_of_residence,
reported_at: reported_at,
status: status_val,
report_type: report_type,
category: category,
additional_info: additional_info,
reported_user_id: reported_user_id,
reported_user_tag: reported_user_tag,
reported_user_username: reported_user_username,
reported_user_discriminator: reported_user_discriminator,
reported_user_avatar_hash: reported_user_avatar_hash,
reported_guild_id: reported_guild_id,
reported_guild_name: reported_guild_name,
reported_guild_icon_hash: reported_guild_icon_hash,
reported_message_id: reported_message_id,
reported_channel_id: reported_channel_id,
reported_channel_name: reported_channel_name,
reported_guild_invite_code: reported_guild_invite_code,
resolved_at: resolved_at,
resolved_by_admin_id: resolved_by_admin_id,
public_comment: public_comment,
message_context: [],
),
)
}
let decoder = {
use reports <- decode.field("reports", decode.list(report_decoder))
decode.success(ListReportsResponse(reports: reports))
}
case json.parse(resp.body, decoder) {
Ok(response) -> Ok(response)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
pub fn resolve_report(
ctx: web.Context,
session: web.Session,
report_id: String,
public_comment: option.Option(String),
audit_log_reason: option.Option(String),
) -> Result(Nil, ApiError) {
let fields = [#("report_id", json.string(report_id))]
let fields = case public_comment {
option.Some(comment) -> [
#("public_comment", json.string(comment)),
..fields
]
option.None -> fields
}
admin_post_with_audit(
ctx,
session,
"/admin/reports/resolve",
fields,
audit_log_reason,
)
}
pub fn search_reports(
ctx: web.Context,
session: web.Session,
query: option.Option(String),
status_filter: option.Option(Int),
type_filter: option.Option(Int),
category_filter: option.Option(String),
limit: Int,
offset: Int,
) -> Result(SearchReportsResponse, ApiError) {
let mut_fields = [#("limit", json.int(limit)), #("offset", json.int(offset))]
let mut_fields = case query {
option.Some(q) if q != "" -> [#("query", json.string(q)), ..mut_fields]
_ -> mut_fields
}
let mut_fields = case status_filter {
option.Some(s) -> [#("status", json.int(s)), ..mut_fields]
option.None -> mut_fields
}
let mut_fields = case type_filter {
option.Some(t) -> [#("report_type", json.int(t)), ..mut_fields]
option.None -> mut_fields
}
let mut_fields = case category_filter {
option.Some(c) if c != "" -> [#("category", json.string(c)), ..mut_fields]
_ -> mut_fields
}
let url = ctx.api_endpoint <> "/admin/reports/search"
let body = json.object(mut_fields) |> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let report_decoder = {
use report_id <- decode.field("report_id", decode.string)
use reporter_id <- decode.field(
"reporter_id",
decode.optional(decode.string),
)
use reporter_tag <- decode.field(
"reporter_tag",
decode.optional(decode.string),
)
use reporter_username <- decode.field(
"reporter_username",
decode.optional(decode.string),
)
use reporter_discriminator <- decode.field(
"reporter_discriminator",
decode.optional(decode.string),
)
use reporter_email <- decode.field(
"reporter_email",
decode.optional(decode.string),
)
use reporter_full_legal_name <- decode.field(
"reporter_full_legal_name",
decode.optional(decode.string),
)
use reporter_country_of_residence <- decode.field(
"reporter_country_of_residence",
decode.optional(decode.string),
)
use reported_at <- decode.field("reported_at", decode.string)
use status_val <- decode.field("status", decode.int)
use report_type <- decode.field("report_type", decode.int)
use category <- decode.field("category", decode.string)
use additional_info <- decode.field(
"additional_info",
decode.optional(decode.string),
)
use reported_user_id <- decode.field(
"reported_user_id",
decode.optional(decode.string),
)
use reported_user_tag <- decode.field(
"reported_user_tag",
decode.optional(decode.string),
)
use reported_user_username <- decode.field(
"reported_user_username",
decode.optional(decode.string),
)
use reported_user_discriminator <- decode.field(
"reported_user_discriminator",
decode.optional(decode.string),
)
use reported_user_avatar_hash <- decode.field(
"reported_user_avatar_hash",
decode.optional(decode.string),
)
use reported_guild_id <- decode.field(
"reported_guild_id",
decode.optional(decode.string),
)
use reported_guild_name <- decode.field(
"reported_guild_name",
decode.optional(decode.string),
)
use reported_guild_invite_code <- decode.field(
"reported_guild_invite_code",
decode.optional(decode.string),
)
decode.success(SearchReportResult(
report_id: report_id,
reporter_id: reporter_id,
reporter_tag: reporter_tag,
reporter_username: reporter_username,
reporter_discriminator: reporter_discriminator,
reporter_email: reporter_email,
reporter_full_legal_name: reporter_full_legal_name,
reporter_country_of_residence: reporter_country_of_residence,
reported_at: reported_at,
status: status_val,
report_type: report_type,
category: category,
additional_info: additional_info,
reported_user_id: reported_user_id,
reported_user_tag: reported_user_tag,
reported_user_username: reported_user_username,
reported_user_discriminator: reported_user_discriminator,
reported_user_avatar_hash: reported_user_avatar_hash,
reported_guild_id: reported_guild_id,
reported_guild_name: reported_guild_name,
reported_guild_invite_code: reported_guild_invite_code,
))
}
let decoder = {
use reports <- decode.field("reports", decode.list(report_decoder))
use total <- decode.field("total", decode.int)
use offset_val <- decode.field("offset", decode.int)
use limit_val <- decode.field("limit", decode.int)
decode.success(SearchReportsResponse(
reports: reports,
total: total,
offset: offset_val,
limit: limit_val,
))
}
case json.parse(resp.body, decoder) {
Ok(result) -> Ok(result)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
pub fn get_report_detail(
ctx: web.Context,
session: web.Session,
report_id: String,
) -> Result(Report, ApiError) {
let url = ctx.api_endpoint <> "/admin/reports/" <> report_id
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Get)
|> request.set_header("authorization", "Bearer " <> session.access_token)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let attachment_decoder = {
use filename <- decode.field("filename", decode.string)
use url <- decode.field("url", decode.string)
decode.success(MessageAttachment(filename: filename, url: url))
}
let context_message_decoder = {
use id <- decode.field("id", decode.string)
use channel_id <- decode.field(
"channel_id",
decode.optional(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 attachments <- decode.optional_field(
"attachments",
[],
decode.list(attachment_decoder),
)
decode.success(Message(
id: id,
channel_id: option.unwrap(channel_id, ""),
author_id: author_id,
author_username: author_username,
content: content,
timestamp: timestamp,
attachments: attachments,
))
}
let report_decoder = {
use report_id <- decode.field("report_id", decode.string)
use reporter_id <- decode.field(
"reporter_id",
decode.optional(decode.string),
)
use reporter_tag <- decode.field(
"reporter_tag",
decode.optional(decode.string),
)
use reporter_username <- decode.field(
"reporter_username",
decode.optional(decode.string),
)
use reporter_discriminator <- decode.field(
"reporter_discriminator",
decode.optional(decode.string),
)
use reporter_email <- decode.field(
"reporter_email",
decode.optional(decode.string),
)
use reporter_full_legal_name <- decode.field(
"reporter_full_legal_name",
decode.optional(decode.string),
)
use reporter_country_of_residence <- decode.field(
"reporter_country_of_residence",
decode.optional(decode.string),
)
use reported_at <- decode.field("reported_at", decode.string)
use status_val <- decode.field("status", decode.int)
use report_type <- decode.field("report_type", decode.int)
use category <- decode.field("category", decode.string)
use additional_info <- decode.field(
"additional_info",
decode.optional(decode.string),
)
use reported_user_id <- decode.field(
"reported_user_id",
decode.optional(decode.string),
)
use reported_user_tag <- decode.field(
"reported_user_tag",
decode.optional(decode.string),
)
use reported_user_username <- decode.field(
"reported_user_username",
decode.optional(decode.string),
)
use reported_user_discriminator <- decode.field(
"reported_user_discriminator",
decode.optional(decode.string),
)
use reported_user_avatar_hash <- decode.field(
"reported_user_avatar_hash",
decode.optional(decode.string),
)
use reported_guild_id <- decode.field(
"reported_guild_id",
decode.optional(decode.string),
)
use reported_guild_name <- decode.field(
"reported_guild_name",
decode.optional(decode.string),
)
use reported_guild_icon_hash <- decode.field(
"reported_guild_icon_hash",
decode.optional(decode.string),
)
use reported_guild_invite_code <- decode.field(
"reported_guild_invite_code",
decode.optional(decode.string),
)
use reported_message_id <- decode.field(
"reported_message_id",
decode.optional(decode.string),
)
use reported_channel_id <- decode.field(
"reported_channel_id",
decode.optional(decode.string),
)
use reported_channel_name <- decode.field(
"reported_channel_name",
decode.optional(decode.string),
)
use resolved_at <- decode.field(
"resolved_at",
decode.optional(decode.string),
)
use resolved_by_admin_id <- decode.field(
"resolved_by_admin_id",
decode.optional(decode.string),
)
use public_comment <- decode.field(
"public_comment",
decode.optional(decode.string),
)
use message_context <- decode.optional_field(
"message_context",
[],
decode.list(context_message_decoder),
)
decode.success(Report(
report_id: report_id,
reporter_id: reporter_id,
reporter_tag: reporter_tag,
reporter_username: reporter_username,
reporter_discriminator: reporter_discriminator,
reporter_email: reporter_email,
reporter_full_legal_name: reporter_full_legal_name,
reporter_country_of_residence: reporter_country_of_residence,
reported_at: reported_at,
status: status_val,
report_type: report_type,
category: category,
additional_info: additional_info,
reported_user_id: reported_user_id,
reported_user_tag: reported_user_tag,
reported_user_username: reported_user_username,
reported_user_discriminator: reported_user_discriminator,
reported_user_avatar_hash: reported_user_avatar_hash,
reported_guild_id: reported_guild_id,
reported_guild_name: reported_guild_name,
reported_guild_icon_hash: reported_guild_icon_hash,
reported_message_id: reported_message_id,
reported_channel_id: reported_channel_id,
reported_channel_name: reported_channel_name,
reported_guild_invite_code: reported_guild_invite_code,
resolved_at: resolved_at,
resolved_by_admin_id: resolved_by_admin_id,
public_comment: public_comment,
message_context: message_context,
))
}
case json.parse(resp.body, report_decoder) {
Ok(result) -> Ok(result)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}

View File

@@ -0,0 +1,202 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common.{
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
}
import fluxer_admin/web.{type Context, type Session}
import gleam/dynamic/decode
import gleam/http
import gleam/http/request
import gleam/httpc
import gleam/json
import gleam/option
pub type RefreshSearchIndexResponse {
RefreshSearchIndexResponse(job_id: String)
}
pub type IndexRefreshStatus {
IndexRefreshStatus(
status: String,
total: option.Option(Int),
indexed: option.Option(Int),
started_at: option.Option(String),
completed_at: option.Option(String),
error: option.Option(String),
)
}
pub fn refresh_search_index(
ctx: Context,
session: Session,
index_type: String,
audit_log_reason: option.Option(String),
) -> Result(RefreshSearchIndexResponse, ApiError) {
refresh_search_index_with_guild(
ctx,
session,
index_type,
option.None,
audit_log_reason,
)
}
pub fn refresh_search_index_with_guild(
ctx: Context,
session: Session,
index_type: String,
guild_id: option.Option(String),
audit_log_reason: option.Option(String),
) -> Result(RefreshSearchIndexResponse, ApiError) {
let fields = case guild_id {
option.Some(id) -> [
#("index_type", json.string(index_type)),
#("guild_id", json.string(id)),
]
option.None -> [#("index_type", json.string(index_type))]
}
let url = ctx.api_endpoint <> "/admin/search/refresh-index"
let body = json.object(fields) |> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
let req = case audit_log_reason {
option.Some(reason) -> request.set_header(req, "x-audit-log-reason", reason)
option.None -> req
}
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let decoder = {
use job_id <- decode.field("job_id", decode.string)
decode.success(RefreshSearchIndexResponse(job_id: job_id))
}
case json.parse(resp.body, decoder) {
Ok(result) -> Ok(result)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
pub fn get_index_refresh_status(
ctx: Context,
session: Session,
job_id: String,
) -> Result(IndexRefreshStatus, ApiError) {
let fields = [#("job_id", json.string(job_id))]
let url = ctx.api_endpoint <> "/admin/search/refresh-status"
let body = json.object(fields) |> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let decoder = {
use status <- decode.field("status", decode.string)
use total <- decode.optional_field(
"total",
option.None,
decode.optional(decode.int),
)
use indexed <- decode.optional_field(
"indexed",
option.None,
decode.optional(decode.int),
)
use started_at <- decode.optional_field(
"started_at",
option.None,
decode.optional(decode.string),
)
use completed_at <- decode.optional_field(
"completed_at",
option.None,
decode.optional(decode.string),
)
use error <- decode.optional_field(
"error",
option.None,
decode.optional(decode.string),
)
decode.success(IndexRefreshStatus(
status: status,
total: total,
indexed: indexed,
started_at: started_at,
completed_at: completed_at,
error: error,
))
}
case json.parse(resp.body, decoder) {
Ok(result) -> Ok(result)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}

View File

@@ -0,0 +1,247 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common.{
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
}
import fluxer_admin/web.{type Context, type Session}
import gleam/dynamic/decode
import gleam/http
import gleam/http/request
import gleam/httpc
import gleam/int
import gleam/json
import gleam/option
pub type ProcessMemoryStats {
ProcessMemoryStats(
guild_id: option.Option(String),
guild_name: String,
guild_icon: option.Option(String),
memory_mb: Float,
member_count: Int,
session_count: Int,
presence_count: Int,
)
}
pub type ProcessMemoryStatsResponse {
ProcessMemoryStatsResponse(guilds: List(ProcessMemoryStats))
}
pub fn get_guild_memory_stats(
ctx: Context,
session: Session,
limit: Int,
) -> Result(ProcessMemoryStatsResponse, ApiError) {
let url = ctx.api_endpoint <> "/admin/gateway/memory-stats"
let body = json.object([#("limit", json.int(limit))]) |> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let guild_decoder = {
use guild_id <- decode.field("guild_id", decode.optional(decode.string))
use guild_name <- decode.field("guild_name", decode.string)
use guild_icon <- decode.field(
"guild_icon",
decode.optional(decode.string),
)
use memory <- decode.field("memory", decode.int)
use member_count <- decode.field("member_count", decode.int)
use session_count <- decode.field("session_count", decode.int)
use presence_count <- decode.field("presence_count", decode.int)
let memory_mb = int.to_float(memory) /. 1_024_000.0
decode.success(ProcessMemoryStats(
guild_id: guild_id,
guild_name: guild_name,
guild_icon: guild_icon,
memory_mb: memory_mb,
member_count: member_count,
session_count: session_count,
presence_count: presence_count,
))
}
let decoder = {
use guilds <- decode.field("guilds", decode.list(guild_decoder))
decode.success(ProcessMemoryStatsResponse(guilds: guilds))
}
case json.parse(resp.body, decoder) {
Ok(result) -> Ok(result)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
pub fn reload_all_guilds(
ctx: Context,
session: Session,
guild_ids: List(String),
) -> Result(Int, ApiError) {
let url = ctx.api_endpoint <> "/admin/gateway/reload-all"
let body =
json.object([
#("guild_ids", json.array(guild_ids, json.string)),
])
|> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let decoder = {
use count <- decode.field("count", decode.int)
decode.success(count)
}
case json.parse(resp.body, decoder) {
Ok(count) -> Ok(count)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
pub type NodeStats {
NodeStats(
status: String,
sessions: Int,
guilds: Int,
presences: Int,
calls: Int,
memory_total: Int,
memory_processes: Int,
memory_system: Int,
process_count: Int,
process_limit: Int,
uptime_seconds: Int,
)
}
pub fn get_node_stats(
ctx: Context,
session: Session,
) -> Result(NodeStats, ApiError) {
let url = ctx.api_endpoint <> "/admin/gateway/stats"
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Get)
|> request.set_header("authorization", "Bearer " <> session.access_token)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let decoder = {
use status <- decode.field("status", decode.string)
use sessions <- decode.field("sessions", decode.int)
use guilds <- decode.field("guilds", decode.int)
use presences <- decode.field("presences", decode.int)
use calls <- decode.field("calls", decode.int)
use memory <- decode.field("memory", {
use total <- decode.field("total", decode.int)
use processes <- decode.field("processes", decode.int)
use system <- decode.field("system", decode.int)
decode.success(#(total, processes, system))
})
use process_count <- decode.field("process_count", decode.int)
use process_limit <- decode.field("process_limit", decode.int)
use uptime_seconds <- decode.field("uptime_seconds", decode.int)
let #(mem_total, mem_proc, mem_sys) = memory
decode.success(NodeStats(
status: status,
sessions: sessions,
guilds: guilds,
presences: presences,
calls: calls,
memory_total: mem_total,
memory_processes: mem_proc,
memory_system: mem_sys,
process_count: process_count,
process_limit: process_limit,
uptime_seconds: uptime_seconds,
))
}
case json.parse(resp.body, decoder) {
Ok(result) -> Ok(result)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> Error(Forbidden("Forbidden"))
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}

View File

@@ -0,0 +1,700 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common.{
type ApiError, type UserLookupResult, Forbidden, NetworkError, NotFound,
ServerError, Unauthorized, admin_post_simple, admin_post_with_audit,
user_lookup_decoder,
}
import fluxer_admin/web
import gleam/dynamic/decode
import gleam/http
import gleam/http/request
import gleam/httpc
import gleam/json
import gleam/option.{type Option}
pub type ContactChangeLogEntry {
ContactChangeLogEntry(
event_id: String,
field: String,
old_value: Option(String),
new_value: Option(String),
reason: String,
actor_user_id: Option(String),
event_at: String,
)
}
pub type ListUserChangeLogResponse {
ListUserChangeLogResponse(
entries: List(ContactChangeLogEntry),
next_page_token: Option(String),
)
}
pub type UserSession {
UserSession(
session_id_hash: String,
created_at: String,
approx_last_used_at: String,
client_ip: String,
client_os: String,
client_platform: String,
client_location: String,
)
}
pub type ListUserSessionsResponse {
ListUserSessionsResponse(sessions: List(UserSession))
}
pub type SearchUsersResponse {
SearchUsersResponse(users: List(UserLookupResult), total: Int)
}
pub type UserGuild {
UserGuild(
id: String,
owner_id: String,
name: String,
features: List(String),
icon: option.Option(String),
banner: option.Option(String),
member_count: Int,
)
}
pub type ListUserGuildsResponse {
ListUserGuildsResponse(guilds: List(UserGuild))
}
pub fn list_user_guilds(
ctx: web.Context,
session: web.Session,
user_id: String,
) -> Result(ListUserGuildsResponse, ApiError) {
let url = ctx.api_endpoint <> "/admin/users/list-guilds"
let body = json.object([#("user_id", json.string(user_id))]) |> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let guild_decoder = {
use id <- decode.field("id", decode.string)
use owner_id <- decode.optional_field("owner_id", "", decode.string)
use name <- decode.field("name", decode.string)
use features <- decode.field("features", decode.list(decode.string))
use icon <- decode.optional_field(
"icon",
option.None,
decode.optional(decode.string),
)
use banner <- decode.optional_field(
"banner",
option.None,
decode.optional(decode.string),
)
use member_count <- decode.optional_field("member_count", 0, decode.int)
decode.success(UserGuild(
id: id,
owner_id: owner_id,
name: name,
features: features,
icon: icon,
banner: banner,
member_count: member_count,
))
}
let decoder = {
use guilds <- decode.field("guilds", decode.list(guild_decoder))
decode.success(ListUserGuildsResponse(guilds: guilds))
}
case json.parse(resp.body, decoder) {
Ok(result) -> Ok(result)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
pub fn list_user_change_log(
ctx: web.Context,
session: web.Session,
user_id: String,
) -> Result(ListUserChangeLogResponse, ApiError) {
let url = ctx.api_endpoint <> "/admin/users/change-log"
let body =
json.object([
#("user_id", json.string(user_id)),
#("limit", json.int(50)),
])
|> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
let entry_decoder = {
use event_id <- decode.field("event_id", decode.string)
use field <- decode.field("field", decode.string)
use old_value <- decode.field("old_value", decode.optional(decode.string))
use new_value <- decode.field("new_value", decode.optional(decode.string))
use reason <- decode.field("reason", decode.string)
use actor_user_id <- decode.field(
"actor_user_id",
decode.optional(decode.string),
)
use event_at <- decode.field("event_at", decode.string)
decode.success(ContactChangeLogEntry(
event_id: event_id,
field: field,
old_value: old_value,
new_value: new_value,
reason: reason,
actor_user_id: actor_user_id,
event_at: event_at,
))
}
let decoder = {
use entries <- decode.field("entries", decode.list(entry_decoder))
use next_page_token <- decode.field(
"next_page_token",
decode.optional(decode.string),
)
decode.success(ListUserChangeLogResponse(
entries: entries,
next_page_token: next_page_token,
))
}
case httpc.send(req) {
Ok(resp) if resp.status == 200 ->
case json.parse(resp.body, decoder) {
Ok(result) -> Ok(result)
Error(_) -> Error(ServerError)
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> Error(Forbidden("Missing permission"))
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
pub fn lookup_user(
ctx: web.Context,
session: web.Session,
query: String,
) -> Result(Option(UserLookupResult), ApiError) {
let url = ctx.api_endpoint <> "/admin/users/lookup"
let body = json.object([#("query", json.string(query))]) |> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let decoder = {
use user <- decode.field("user", decode.optional(user_lookup_decoder()))
decode.success(user)
}
case json.parse(resp.body, decoder) {
Ok(result) -> Ok(result)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
pub fn update_user_flags(
ctx: web.Context,
session: web.Session,
user_id: String,
add_flags: List(String),
remove_flags: List(String),
) -> Result(Nil, ApiError) {
let url = ctx.api_endpoint <> "/admin/users/update-flags"
let body =
json.object([
#("user_id", json.string(user_id)),
#("add_flags", json.array(add_flags, json.string)),
#("remove_flags", json.array(remove_flags, json.string)),
])
|> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> Ok(Nil)
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
pub fn disable_mfa(
ctx: web.Context,
session: web.Session,
user_id: String,
) -> Result(Nil, ApiError) {
admin_post_simple(ctx, session, "/admin/users/disable-mfa", [
#("user_id", json.string(user_id)),
])
}
pub fn verify_email(
ctx: web.Context,
session: web.Session,
user_id: String,
) -> Result(Nil, ApiError) {
admin_post_simple(ctx, session, "/admin/users/verify-email", [
#("user_id", json.string(user_id)),
])
}
pub fn unlink_phone(
ctx: web.Context,
session: web.Session,
user_id: String,
) -> Result(Nil, ApiError) {
admin_post_simple(ctx, session, "/admin/users/unlink-phone", [
#("user_id", json.string(user_id)),
])
}
pub fn terminate_sessions(
ctx: web.Context,
session: web.Session,
user_id: String,
) -> Result(Nil, ApiError) {
admin_post_simple(ctx, session, "/admin/users/terminate-sessions", [
#("user_id", json.string(user_id)),
])
}
pub fn temp_ban_user(
ctx: web.Context,
session: web.Session,
user_id: String,
duration_hours: Int,
reason: option.Option(String),
private_reason: option.Option(String),
) -> Result(Nil, ApiError) {
let fields = [
#("user_id", json.string(user_id)),
#("duration_hours", json.int(duration_hours)),
]
let fields = case reason {
option.Some(r) -> [#("reason", json.string(r)), ..fields]
option.None -> fields
}
admin_post_with_audit(
ctx,
session,
"/admin/users/temp-ban",
fields,
private_reason,
)
}
pub fn unban_user(
ctx: web.Context,
session: web.Session,
user_id: String,
) -> Result(Nil, ApiError) {
admin_post_simple(ctx, session, "/admin/users/unban", [
#("user_id", json.string(user_id)),
])
}
pub fn schedule_deletion(
ctx: web.Context,
session: web.Session,
user_id: String,
reason_code: Int,
public_reason: option.Option(String),
days_until_deletion: Int,
private_reason: option.Option(String),
) -> Result(Nil, ApiError) {
let fields = [
#("user_id", json.string(user_id)),
#("reason_code", json.int(reason_code)),
#("days_until_deletion", json.int(days_until_deletion)),
]
let fields = case public_reason {
option.Some(r) -> [#("public_reason", json.string(r)), ..fields]
option.None -> fields
}
admin_post_with_audit(
ctx,
session,
"/admin/users/schedule-deletion",
fields,
private_reason,
)
}
pub fn cancel_deletion(
ctx: web.Context,
session: web.Session,
user_id: String,
) -> Result(Nil, ApiError) {
admin_post_simple(ctx, session, "/admin/users/cancel-deletion", [
#("user_id", json.string(user_id)),
])
}
pub fn cancel_bulk_message_deletion(
ctx: web.Context,
session: web.Session,
user_id: String,
) -> Result(Nil, ApiError) {
admin_post_simple(ctx, session, "/admin/users/cancel-bulk-message-deletion", [
#("user_id", json.string(user_id)),
])
}
pub fn change_email(
ctx: web.Context,
session: web.Session,
user_id: String,
email: String,
) -> Result(Nil, ApiError) {
admin_post_simple(ctx, session, "/admin/users/change-email", [
#("user_id", json.string(user_id)),
#("email", json.string(email)),
])
}
pub fn send_password_reset(
ctx: web.Context,
session: web.Session,
user_id: String,
) -> Result(Nil, ApiError) {
admin_post_simple(ctx, session, "/admin/users/send-password-reset", [
#("user_id", json.string(user_id)),
])
}
pub fn update_suspicious_activity_flags(
ctx: web.Context,
session: web.Session,
user_id: String,
flags: Int,
) -> Result(Nil, ApiError) {
admin_post_simple(
ctx,
session,
"/admin/users/update-suspicious-activity-flags",
[#("user_id", json.string(user_id)), #("flags", json.int(flags))],
)
}
pub fn get_current_admin(
ctx: web.Context,
session: web.Session,
) -> Result(Option(UserLookupResult), ApiError) {
lookup_user(ctx, session, session.user_id)
}
pub fn set_user_acls(
ctx: web.Context,
session: web.Session,
user_id: String,
acls: List(String),
) -> Result(Nil, ApiError) {
admin_post_simple(ctx, session, "/admin/users/set-acls", [
#("user_id", json.string(user_id)),
#("acls", json.array(acls, json.string)),
])
}
pub fn clear_user_fields(
ctx: web.Context,
session: web.Session,
user_id: String,
fields: List(String),
) -> Result(Nil, ApiError) {
admin_post_simple(ctx, session, "/admin/users/clear-fields", [
#("user_id", json.string(user_id)),
#("fields", json.array(fields, json.string)),
])
}
pub fn set_bot_status(
ctx: web.Context,
session: web.Session,
user_id: String,
bot: Bool,
) -> Result(Nil, ApiError) {
admin_post_simple(ctx, session, "/admin/users/set-bot-status", [
#("user_id", json.string(user_id)),
#("bot", json.bool(bot)),
])
}
pub fn set_system_status(
ctx: web.Context,
session: web.Session,
user_id: String,
system: Bool,
) -> Result(Nil, ApiError) {
admin_post_simple(ctx, session, "/admin/users/set-system-status", [
#("user_id", json.string(user_id)),
#("system", json.bool(system)),
])
}
pub fn change_username(
ctx: web.Context,
session: web.Session,
user_id: String,
username: String,
discriminator: Option(Int),
) -> Result(Nil, ApiError) {
let fields = case discriminator {
option.Some(disc) -> [
#("user_id", json.string(user_id)),
#("username", json.string(username)),
#("discriminator", json.int(disc)),
]
option.None -> [
#("user_id", json.string(user_id)),
#("username", json.string(username)),
]
}
admin_post_simple(ctx, session, "/admin/users/change-username", fields)
}
pub fn change_dob(
ctx: web.Context,
session: web.Session,
user_id: String,
date_of_birth: String,
) -> Result(Nil, ApiError) {
admin_post_simple(ctx, session, "/admin/users/change-dob", [
#("user_id", json.string(user_id)),
#("date_of_birth", json.string(date_of_birth)),
])
}
pub fn list_user_sessions(
ctx: web.Context,
session: web.Session,
user_id: String,
) -> Result(ListUserSessionsResponse, ApiError) {
let url = ctx.api_endpoint <> "/admin/users/list-sessions"
let body = json.object([#("user_id", json.string(user_id))]) |> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let session_decoder = {
use session_id_hash <- decode.field("session_id_hash", decode.string)
use created_at <- decode.field("created_at", decode.string)
use approx_last_used_at <- decode.field(
"approx_last_used_at",
decode.string,
)
use client_ip <- decode.field("client_ip", decode.string)
use client_os <- decode.field("client_os", decode.string)
use client_platform <- decode.field("client_platform", decode.string)
use client_location <- decode.field("client_location", decode.string)
decode.success(UserSession(
session_id_hash: session_id_hash,
created_at: created_at,
approx_last_used_at: approx_last_used_at,
client_ip: client_ip,
client_os: client_os,
client_platform: client_platform,
client_location: client_location,
))
}
let decoder = {
use sessions <- decode.field("sessions", decode.list(session_decoder))
decode.success(ListUserSessionsResponse(sessions: sessions))
}
case json.parse(resp.body, decoder) {
Ok(result) -> Ok(result)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
pub fn search_users(
ctx: web.Context,
session: web.Session,
query: String,
limit: Int,
offset: Int,
) -> Result(SearchUsersResponse, ApiError) {
let url = ctx.api_endpoint <> "/admin/users/search"
let body =
json.object([
#("query", json.string(query)),
#("limit", json.int(limit)),
#("offset", json.int(offset)),
])
|> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let decoder = {
use users <- decode.field("users", decode.list(user_lookup_decoder()))
use total <- decode.field("total", decode.int)
decode.success(SearchUsersResponse(users: users, total: total))
}
case json.parse(resp.body, decoder) {
Ok(result) -> Ok(result)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}

View File

@@ -0,0 +1,160 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common.{
type ApiError, type UserLookupResult, Forbidden, NetworkError, NotFound,
ServerError, Unauthorized, admin_post_simple, user_lookup_decoder,
}
import fluxer_admin/web
import gleam/dynamic/decode
import gleam/http
import gleam/http/request
import gleam/httpc
import gleam/json
pub type PendingVerificationMetadata {
PendingVerificationMetadata(key: String, value: String)
}
pub type PendingVerification {
PendingVerification(
user_id: String,
created_at: String,
user: UserLookupResult,
metadata: List(PendingVerificationMetadata),
)
}
pub type PendingVerificationsResponse {
PendingVerificationsResponse(pending_verifications: List(PendingVerification))
}
pub fn list_pending_verifications(
ctx: web.Context,
session: web.Session,
limit: Int,
) -> Result(PendingVerificationsResponse, ApiError) {
let url = ctx.api_endpoint <> "/admin/pending-verifications/list"
let body = json.object([#("limit", json.int(limit))]) |> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let pending_verification_metadata_decoder = {
use key <- decode.field("key", decode.string)
use value <- decode.field("value", decode.string)
decode.success(PendingVerificationMetadata(key: key, value: value))
}
let pending_verification_decoder = {
use user_id <- decode.field("user_id", decode.string)
use created_at <- decode.field("created_at", decode.string)
use user <- decode.field("user", user_lookup_decoder())
use metadata <- decode.field(
"metadata",
decode.list(pending_verification_metadata_decoder),
)
decode.success(PendingVerification(
user_id: user_id,
created_at: created_at,
user: user,
metadata: metadata,
))
}
let decoder = {
use pending_verifications <- decode.field(
"pending_verifications",
decode.list(pending_verification_decoder),
)
decode.success(PendingVerificationsResponse(
pending_verifications: pending_verifications,
))
}
case json.parse(resp.body, decoder) {
Ok(response) -> Ok(response)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> {
let message_decoder = {
use message <- decode.field("message", decode.string)
decode.success(message)
}
let message = case json.parse(resp.body, message_decoder) {
Ok(msg) -> msg
Error(_) ->
"Missing required permissions. Contact an administrator to request access."
}
Error(Forbidden(message))
}
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
pub fn approve_registration(
ctx: web.Context,
session: web.Session,
user_id: String,
) -> Result(Nil, ApiError) {
admin_post_simple(ctx, session, "/admin/pending-verifications/approve", [
#("user_id", json.string(user_id)),
])
}
pub fn reject_registration(
ctx: web.Context,
session: web.Session,
user_id: String,
) -> Result(Nil, ApiError) {
admin_post_simple(ctx, session, "/admin/pending-verifications/reject", [
#("user_id", json.string(user_id)),
])
}
pub fn bulk_approve_registrations(
ctx: web.Context,
session: web.Session,
user_ids: List(String),
) -> Result(Nil, ApiError) {
admin_post_simple(ctx, session, "/admin/pending-verifications/bulk-approve", [
#("user_ids", json.array(user_ids, json.string)),
])
}
pub fn bulk_reject_registrations(
ctx: web.Context,
session: web.Session,
user_ids: List(String),
) -> Result(Nil, ApiError) {
admin_post_simple(ctx, session, "/admin/pending-verifications/bulk-reject", [
#("user_ids", json.array(user_ids, json.string)),
])
}

View File

@@ -0,0 +1,567 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common.{
type ApiError, Forbidden, NetworkError, NotFound, ServerError, Unauthorized,
admin_post_with_audit,
}
import fluxer_admin/web.{type Context, type Session}
import gleam/dynamic/decode
import gleam/http
import gleam/http/request
import gleam/httpc
import gleam/json
import gleam/option
pub type VoiceRegion {
VoiceRegion(
id: String,
name: String,
emoji: String,
latitude: Float,
longitude: Float,
is_default: Bool,
vip_only: Bool,
required_guild_features: List(String),
allowed_guild_ids: List(String),
allowed_user_ids: List(String),
created_at: option.Option(String),
updated_at: option.Option(String),
servers: option.Option(List(VoiceServer)),
)
}
pub type VoiceServer {
VoiceServer(
region_id: String,
server_id: String,
endpoint: String,
is_active: Bool,
vip_only: Bool,
required_guild_features: List(String),
allowed_guild_ids: List(String),
allowed_user_ids: List(String),
created_at: option.Option(String),
updated_at: option.Option(String),
)
}
pub type ListVoiceRegionsResponse {
ListVoiceRegionsResponse(regions: List(VoiceRegion))
}
pub type GetVoiceRegionResponse {
GetVoiceRegionResponse(region: option.Option(VoiceRegion))
}
pub type ListVoiceServersResponse {
ListVoiceServersResponse(servers: List(VoiceServer))
}
pub type GetVoiceServerResponse {
GetVoiceServerResponse(server: option.Option(VoiceServer))
}
fn voice_region_decoder() {
use id <- decode.field("id", decode.string)
use name <- decode.field("name", decode.string)
use emoji <- decode.field("emoji", decode.string)
use latitude <- decode.field("latitude", decode.float)
use longitude <- decode.field("longitude", decode.float)
use is_default <- decode.field("is_default", decode.bool)
use vip_only <- decode.field("vip_only", decode.bool)
use required_guild_features <- decode.field(
"required_guild_features",
decode.list(decode.string),
)
use allowed_guild_ids <- decode.field(
"allowed_guild_ids",
decode.list(decode.string),
)
use allowed_user_ids <- decode.field(
"allowed_user_ids",
decode.list(decode.string),
)
use created_at <- decode.field("created_at", decode.optional(decode.string))
use updated_at <- decode.field("updated_at", decode.optional(decode.string))
decode.success(VoiceRegion(
id: id,
name: name,
emoji: emoji,
latitude: latitude,
longitude: longitude,
is_default: is_default,
vip_only: vip_only,
required_guild_features: required_guild_features,
allowed_guild_ids: allowed_guild_ids,
allowed_user_ids: allowed_user_ids,
created_at: created_at,
updated_at: updated_at,
servers: option.None,
))
}
fn voice_region_with_servers_decoder() {
use id <- decode.field("id", decode.string)
use name <- decode.field("name", decode.string)
use emoji <- decode.field("emoji", decode.string)
use latitude <- decode.field("latitude", decode.float)
use longitude <- decode.field("longitude", decode.float)
use is_default <- decode.field("is_default", decode.bool)
use vip_only <- decode.field("vip_only", decode.bool)
use required_guild_features <- decode.field(
"required_guild_features",
decode.list(decode.string),
)
use allowed_guild_ids <- decode.field(
"allowed_guild_ids",
decode.list(decode.string),
)
use allowed_user_ids <- decode.field(
"allowed_user_ids",
decode.list(decode.string),
)
use created_at <- decode.field("created_at", decode.optional(decode.string))
use updated_at <- decode.field("updated_at", decode.optional(decode.string))
use servers <- decode.field("servers", decode.list(voice_server_decoder()))
decode.success(VoiceRegion(
id: id,
name: name,
emoji: emoji,
latitude: latitude,
longitude: longitude,
is_default: is_default,
vip_only: vip_only,
required_guild_features: required_guild_features,
allowed_guild_ids: allowed_guild_ids,
allowed_user_ids: allowed_user_ids,
created_at: created_at,
updated_at: updated_at,
servers: option.Some(servers),
))
}
fn voice_server_decoder() {
use region_id <- decode.field("region_id", decode.string)
use server_id <- decode.field("server_id", decode.string)
use endpoint <- decode.field("endpoint", decode.string)
use is_active <- decode.field("is_active", decode.bool)
use vip_only <- decode.field("vip_only", decode.bool)
use required_guild_features <- decode.field(
"required_guild_features",
decode.list(decode.string),
)
use allowed_guild_ids <- decode.field(
"allowed_guild_ids",
decode.list(decode.string),
)
use allowed_user_ids <- decode.field(
"allowed_user_ids",
decode.list(decode.string),
)
use created_at <- decode.field("created_at", decode.optional(decode.string))
use updated_at <- decode.field("updated_at", decode.optional(decode.string))
decode.success(VoiceServer(
region_id: region_id,
server_id: server_id,
endpoint: endpoint,
is_active: is_active,
vip_only: vip_only,
required_guild_features: required_guild_features,
allowed_guild_ids: allowed_guild_ids,
allowed_user_ids: allowed_user_ids,
created_at: created_at,
updated_at: updated_at,
))
}
pub fn list_voice_regions(
ctx: Context,
session: Session,
include_servers: Bool,
) -> Result(ListVoiceRegionsResponse, ApiError) {
let url = ctx.api_endpoint <> "/admin/voice/regions/list"
let body =
json.object([#("include_servers", json.bool(include_servers))])
|> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let decoder_fn = case include_servers {
True -> voice_region_with_servers_decoder
False -> voice_region_decoder
}
let decoder = {
use regions <- decode.field("regions", decode.list(decoder_fn()))
decode.success(ListVoiceRegionsResponse(regions: regions))
}
case json.parse(resp.body, decoder) {
Ok(response) -> Ok(response)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> Error(Forbidden("Access denied"))
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
pub fn get_voice_region(
ctx: Context,
session: Session,
region_id: String,
include_servers: Bool,
) -> Result(GetVoiceRegionResponse, ApiError) {
let url = ctx.api_endpoint <> "/admin/voice/regions/get"
let body =
json.object([
#("id", json.string(region_id)),
#("include_servers", json.bool(include_servers)),
])
|> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let decoder_fn = case include_servers {
True -> voice_region_with_servers_decoder
False -> voice_region_decoder
}
let decoder = {
use region <- decode.field("region", decode.optional(decoder_fn()))
decode.success(GetVoiceRegionResponse(region: region))
}
case json.parse(resp.body, decoder) {
Ok(response) -> Ok(response)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> Error(Forbidden("Access denied"))
Ok(resp) if resp.status == 404 -> Error(NotFound)
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
pub fn create_voice_region(
ctx: Context,
session: Session,
id: String,
name: String,
emoji: String,
latitude: Float,
longitude: Float,
is_default: Bool,
vip_only: Bool,
required_guild_features: List(String),
allowed_guild_ids: List(String),
audit_log_reason: option.Option(String),
) -> Result(Nil, ApiError) {
admin_post_with_audit(
ctx,
session,
"/admin/voice/regions/create",
[
#("id", json.string(id)),
#("name", json.string(name)),
#("emoji", json.string(emoji)),
#("latitude", json.float(latitude)),
#("longitude", json.float(longitude)),
#("is_default", json.bool(is_default)),
#("vip_only", json.bool(vip_only)),
#(
"required_guild_features",
json.array(required_guild_features, json.string),
),
#("allowed_guild_ids", json.array(allowed_guild_ids, json.string)),
],
audit_log_reason,
)
}
pub fn update_voice_region(
ctx: Context,
session: Session,
id: String,
name: option.Option(String),
emoji: option.Option(String),
latitude: option.Option(Float),
longitude: option.Option(Float),
is_default: option.Option(Bool),
vip_only: option.Option(Bool),
required_guild_features: option.Option(List(String)),
allowed_guild_ids: option.Option(List(String)),
audit_log_reason: option.Option(String),
) -> Result(Nil, ApiError) {
let base_fields = [#("id", json.string(id))]
let fields = case name {
option.Some(n) -> [#("name", json.string(n)), ..base_fields]
option.None -> base_fields
}
let fields = case emoji {
option.Some(e) -> [#("emoji", json.string(e)), ..fields]
option.None -> fields
}
let fields = case latitude {
option.Some(lat) -> [#("latitude", json.float(lat)), ..fields]
option.None -> fields
}
let fields = case longitude {
option.Some(lng) -> [#("longitude", json.float(lng)), ..fields]
option.None -> fields
}
let fields = case is_default {
option.Some(d) -> [#("is_default", json.bool(d)), ..fields]
option.None -> fields
}
let fields = case vip_only {
option.Some(v) -> [#("vip_only", json.bool(v)), ..fields]
option.None -> fields
}
let fields = case required_guild_features {
option.Some(features) -> [
#("required_guild_features", json.array(features, json.string)),
..fields
]
option.None -> fields
}
let fields = case allowed_guild_ids {
option.Some(ids) -> [
#("allowed_guild_ids", json.array(ids, json.string)),
..fields
]
option.None -> fields
}
admin_post_with_audit(
ctx,
session,
"/admin/voice/regions/update",
fields,
audit_log_reason,
)
}
pub fn delete_voice_region(
ctx: Context,
session: Session,
id: String,
audit_log_reason: option.Option(String),
) -> Result(Nil, ApiError) {
admin_post_with_audit(
ctx,
session,
"/admin/voice/regions/delete",
[#("id", json.string(id))],
audit_log_reason,
)
}
pub fn list_voice_servers(
ctx: Context,
session: Session,
region_id: String,
) -> Result(ListVoiceServersResponse, ApiError) {
let url = ctx.api_endpoint <> "/admin/voice/servers/list"
let body =
json.object([#("region_id", json.string(region_id))]) |> json.to_string
let assert Ok(req) = request.to(url)
let req =
req
|> request.set_method(http.Post)
|> request.set_header("authorization", "Bearer " <> session.access_token)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
case httpc.send(req) {
Ok(resp) if resp.status == 200 -> {
let decoder = {
use servers <- decode.field(
"servers",
decode.list(voice_server_decoder()),
)
decode.success(ListVoiceServersResponse(servers: servers))
}
case json.parse(resp.body, decoder) {
Ok(response) -> Ok(response)
Error(_) -> Error(ServerError)
}
}
Ok(resp) if resp.status == 401 -> Error(Unauthorized)
Ok(resp) if resp.status == 403 -> Error(Forbidden("Access denied"))
Ok(_resp) -> Error(ServerError)
Error(_) -> Error(NetworkError)
}
}
pub fn create_voice_server(
ctx: Context,
session: Session,
region_id: String,
server_id: String,
endpoint: String,
api_key: String,
api_secret: String,
is_active: Bool,
vip_only: Bool,
required_guild_features: List(String),
allowed_guild_ids: List(String),
audit_log_reason: option.Option(String),
) -> Result(Nil, ApiError) {
admin_post_with_audit(
ctx,
session,
"/admin/voice/servers/create",
[
#("region_id", json.string(region_id)),
#("server_id", json.string(server_id)),
#("endpoint", json.string(endpoint)),
#("api_key", json.string(api_key)),
#("api_secret", json.string(api_secret)),
#("is_active", json.bool(is_active)),
#("vip_only", json.bool(vip_only)),
#(
"required_guild_features",
json.array(required_guild_features, json.string),
),
#("allowed_guild_ids", json.array(allowed_guild_ids, json.string)),
],
audit_log_reason,
)
}
pub fn update_voice_server(
ctx: Context,
session: Session,
region_id: String,
server_id: String,
endpoint: option.Option(String),
api_key: option.Option(String),
api_secret: option.Option(String),
is_active: option.Option(Bool),
vip_only: option.Option(Bool),
required_guild_features: option.Option(List(String)),
allowed_guild_ids: option.Option(List(String)),
audit_log_reason: option.Option(String),
) -> Result(Nil, ApiError) {
let base_fields = [
#("region_id", json.string(region_id)),
#("server_id", json.string(server_id)),
]
let fields = case endpoint {
option.Some(e) -> [#("endpoint", json.string(e)), ..base_fields]
option.None -> base_fields
}
let fields = case api_key {
option.Some(k) -> [#("api_key", json.string(k)), ..fields]
option.None -> fields
}
let fields = case api_secret {
option.Some(s) -> [#("api_secret", json.string(s)), ..fields]
option.None -> fields
}
let fields = case is_active {
option.Some(a) -> [#("is_active", json.bool(a)), ..fields]
option.None -> fields
}
let fields = case vip_only {
option.Some(v) -> [#("vip_only", json.bool(v)), ..fields]
option.None -> fields
}
let fields = case required_guild_features {
option.Some(features) -> [
#("required_guild_features", json.array(features, json.string)),
..fields
]
option.None -> fields
}
let fields = case allowed_guild_ids {
option.Some(ids) -> [
#("allowed_guild_ids", json.array(ids, json.string)),
..fields
]
option.None -> fields
}
admin_post_with_audit(
ctx,
session,
"/admin/voice/servers/update",
fields,
audit_log_reason,
)
}
pub fn delete_voice_server(
ctx: Context,
session: Session,
region_id: String,
server_id: String,
audit_log_reason: option.Option(String),
) -> Result(Nil, ApiError) {
admin_post_with_audit(
ctx,
session,
"/admin/voice/servers/delete",
[
#("region_id", json.string(region_id)),
#("server_id", json.string(server_id)),
],
audit_log_reason,
)
}

View File

@@ -0,0 +1,138 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/web
import gleam/int
import gleam/list
import gleam/option.{type Option}
import gleam/string
pub fn get_user_avatar_url(
media_endpoint: String,
cdn_endpoint: String,
user_id: String,
avatar: Option(String),
animated: Bool,
asset_version: String,
) -> String {
case avatar {
option.Some(hash) -> {
let is_animated = string.starts_with(hash, "a_")
let actual_hash = case is_animated {
True -> string.drop_start(hash, 2)
False -> hash
}
let should_animate = is_animated && animated
let format = case should_animate {
True -> "gif"
False -> "webp"
}
media_endpoint
<> "/avatars/"
<> user_id
<> "/"
<> actual_hash
<> "."
<> format
<> "?size=160"
}
option.None -> get_default_avatar(cdn_endpoint, user_id, asset_version)
}
}
fn get_default_avatar(
cdn_endpoint: String,
user_id: String,
asset_version: String,
) -> String {
let id = do_parse_bigint(user_id)
let index = do_rem(id, 6)
cdn_endpoint
<> "/avatars/"
<> int.to_string(index)
<> ".png"
|> web.cache_busted_with_version(asset_version)
}
@external(erlang, "erlang", "binary_to_integer")
fn do_parse_bigint(id: String) -> Int
@external(erlang, "erlang", "rem")
fn do_rem(a: Int, b: Int) -> Int
pub fn get_guild_icon_url(
media_proxy_endpoint: String,
guild_id: String,
icon: Option(String),
animated: Bool,
) -> Option(String) {
case icon {
option.Some(hash) -> {
let is_animated = string.starts_with(hash, "a_")
let actual_hash = case is_animated {
True -> string.drop_start(hash, 2)
False -> hash
}
let should_animate = is_animated && animated
let format = case should_animate {
True -> "gif"
False -> "webp"
}
option.Some(
media_proxy_endpoint
<> "/icons/"
<> guild_id
<> "/"
<> actual_hash
<> "."
<> format
<> "?size=160",
)
}
option.None -> option.None
}
}
pub fn get_initials_from_name(name: String) -> String {
name
|> string.to_graphemes
|> do_get_initials(True, [])
|> list.reverse
|> string.join("")
|> string.uppercase
}
fn do_get_initials(
chars: List(String),
is_start: Bool,
acc: List(String),
) -> List(String) {
case chars {
[] -> acc
[char, ..rest] -> {
case char {
" " -> do_get_initials(rest, True, acc)
_ -> {
case is_start {
True -> do_get_initials(rest, False, [char, ..acc])
False -> do_get_initials(rest, False, acc)
}
}
}
}
}
}

View File

@@ -0,0 +1,73 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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 gleam/int
import gleam/list
pub type Badge {
Badge(name: String, icon: String)
}
const flag_staff = 1
const flag_ctp_member = 2
const flag_partner = 4
const flag_bug_hunter = 8
pub fn get_user_badges(cdn_endpoint: String, flags: String) -> List(Badge) {
case int.parse(flags) {
Ok(flags_int) -> {
[]
|> add_badge_if_has_flag(
flags_int,
flag_staff,
Badge("Staff", cdn_endpoint <> "/badges/staff.svg"),
)
|> add_badge_if_has_flag(
flags_int,
flag_ctp_member,
Badge("CTP Member", cdn_endpoint <> "/badges/ctp.svg"),
)
|> add_badge_if_has_flag(
flags_int,
flag_partner,
Badge("Partner", cdn_endpoint <> "/badges/partner.svg"),
)
|> add_badge_if_has_flag(
flags_int,
flag_bug_hunter,
Badge("Bug Hunter", cdn_endpoint <> "/badges/bug-hunter.svg"),
)
|> list.reverse
}
Error(_) -> []
}
}
fn add_badge_if_has_flag(
badges: List(Badge),
flags: Int,
flag: Int,
badge: Badge,
) -> List(Badge) {
case int.bitwise_and(flags, flag) == flag {
True -> [badge, ..badges]
False -> badges
}
}

View File

@@ -0,0 +1,61 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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 gleam/string
pub fn format_timestamp(timestamp: String) -> String {
case string.split(timestamp, "T") {
[date_part, time_part] -> {
let time_clean = case string.split(time_part, ".") {
[hms, _] -> hms
_ -> time_part
}
let time_clean = string.replace(time_clean, "Z", "")
case string.split(time_clean, ":") {
[hour, minute, _] -> date_part <> " " <> hour <> ":" <> minute
_ -> timestamp
}
}
_ -> timestamp
}
}
pub fn format_date(timestamp: String) -> String {
case string.split(timestamp, "T") {
[date_part, _] -> date_part
_ -> timestamp
}
}
pub fn format_time(timestamp: String) -> String {
case string.split(timestamp, "T") {
[_, time_part] -> {
let time_clean = case string.split(time_part, ".") {
[hms, _] -> hms
_ -> time_part
}
let time_clean = string.replace(time_clean, "Z", "")
case string.split(time_clean, ":") {
[hour, minute, _] -> hour <> ":" <> minute
_ -> timestamp
}
}
_ -> timestamp
}
}

View File

@@ -0,0 +1,26 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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 lustre/attribute as a
import lustre/element/html as h
pub fn render() {
let script =
"(function(){const configs=[{selectId:'user-deletion-reason',inputId:'user-deletion-days'},{selectId:'bulk-deletion-reason',inputId:'bulk-deletion-days'}];const userReason='1';const userMin=14;const defaultMin=60;const update=(select,input)=>{const min=select.value===userReason?userMin:defaultMin;input.min=min.toString();const current=parseInt(input.value,10);if(isNaN(current)||current<min){input.value=min.toString();}};configs.forEach(({selectId,inputId})=>{const select=document.getElementById(selectId);const input=document.getElementById(inputId);if(!select||!input){return;}select.addEventListener('change',()=>update(select,input));update(select,input);});})();"
h.script([a.attribute("defer", "defer")], script)
}

View File

@@ -0,0 +1,108 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common
import fluxer_admin/web.{type Context, href}
import gleam/option
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
pub fn error_view(error: common.ApiError) {
h.div(
[a.class("bg-red-50 border border-red-200 rounded-lg p-6 text-center")],
[
h.p([a.class("text-red-800 text-sm font-medium mb-2")], [
element.text("Error"),
]),
h.p([a.class("text-red-600")], [
element.text(case error {
common.Unauthorized -> "Unauthorized"
common.Forbidden(msg) -> "Forbidden - " <> msg
common.NotFound -> "Not found"
common.ServerError -> "Server error"
common.NetworkError -> "Network error"
}),
]),
],
)
}
pub fn api_error_view(
ctx: Context,
err: common.ApiError,
back_url: option.Option(String),
back_label: option.Option(String),
) {
let #(title, message) = case err {
common.Unauthorized -> #(
"Authentication Required",
"Your session has expired. Please log in again.",
)
common.Forbidden(msg) -> #("Permission Denied", msg)
common.NotFound -> #("Not Found", "The requested resource was not found.")
common.ServerError -> #(
"Server Error",
"An internal server error occurred. Please try again later.",
)
common.NetworkError -> #(
"Network Error",
"Could not connect to the API. Please try again later.",
)
}
h.div([a.class("max-w-4xl mx-auto")], [
h.div([a.class("bg-red-50 border border-red-200 rounded-lg p-8")], [
h.div([a.class("flex items-start gap-4")], [
h.div(
[
a.class(
"flex-shrink-0 w-12 h-12 bg-red-100 rounded-full flex items-center justify-center",
),
],
[
h.span([a.class("text-red-600 text-base font-semibold")], [
element.text("!"),
]),
],
),
h.div([a.class("flex-1")], [
h.h2([a.class("text-base font-semibold text-red-900 mb-2")], [
element.text(title),
]),
h.p([a.class("text-red-700 mb-6")], [element.text(message)]),
case back_url {
option.Some(url) ->
h.a(
[
href(ctx, url),
a.class(
"inline-flex items-center gap-2 px-4 py-2 bg-red-900 text-white rounded-lg text-sm font-medium hover:bg-red-800 transition-colors",
),
],
[
h.span([a.class("text-lg")], [element.text("")]),
element.text(option.unwrap(back_label, "Go Back")),
],
)
option.None -> element.none()
},
]),
]),
]),
])
}

View File

@@ -0,0 +1,152 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/web.{type Context}
import gleam/list
import gleam/option.{type Option}
import gleam/string
import gleam/uri
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Request, type Response}
pub type Flash {
Flash(message: String, flash_type: FlashType)
}
pub type FlashType {
Success
Error
Info
Warning
}
pub fn flash_type_to_string(flash_type: FlashType) -> String {
case flash_type {
Success -> "success"
Error -> "error"
Info -> "info"
Warning -> "warning"
}
}
pub fn parse_flash_type(type_str: String) -> FlashType {
case type_str {
"success" -> Success
"error" -> Error
"warning" -> Warning
"info" | _ -> Info
}
}
fn flash_classes(flash_type: FlashType) -> String {
case flash_type {
Success ->
"bg-green-50 border border-green-200 rounded-lg p-4 text-green-800"
Error -> "bg-red-50 border border-red-200 rounded-lg p-4 text-red-800"
Warning ->
"bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-yellow-800"
Info -> "bg-blue-50 border border-blue-200 rounded-lg p-4 text-blue-800"
}
}
pub fn flash_view(
message: Option(String),
flash_type: Option(FlashType),
) -> element.Element(t) {
case message {
option.Some(msg) -> {
let type_ = option.unwrap(flash_type, Info)
h.div([a.class(flash_classes(type_))], [element.text(msg)])
}
option.None -> element.none()
}
}
pub fn redirect_url(
path: String,
message: String,
flash_type: FlashType,
) -> String {
let encoded_message = uri.percent_encode(message)
let type_param = flash_type_to_string(flash_type)
case string.contains(path, "?") {
True -> path <> "&flash=" <> encoded_message <> "&flash_type=" <> type_param
False ->
path <> "?flash=" <> encoded_message <> "&flash_type=" <> type_param
}
}
pub fn redirect_with_success(
ctx: Context,
path: String,
message: String,
) -> Response {
wisp.redirect(web.prepend_base_path(ctx, redirect_url(path, message, Success)))
}
pub fn redirect_with_error(
ctx: Context,
path: String,
message: String,
) -> Response {
wisp.redirect(web.prepend_base_path(ctx, redirect_url(path, message, Error)))
}
pub fn redirect_with_info(
ctx: Context,
path: String,
message: String,
) -> Response {
wisp.redirect(web.prepend_base_path(ctx, redirect_url(path, message, Info)))
}
pub fn redirect_with_warning(
ctx: Context,
path: String,
message: String,
) -> Response {
wisp.redirect(web.prepend_base_path(ctx, redirect_url(path, message, Warning)))
}
pub fn from_request(req: Request) -> Option(Flash) {
let query = wisp.get_query(req)
let flash_msg = list.key_find(query, "flash") |> option.from_result
let flash_type_str = list.key_find(query, "flash_type") |> option.from_result
case flash_msg {
option.Some(msg) -> {
let type_ = case flash_type_str {
option.Some(type_str) -> parse_flash_type(type_str)
option.None -> Info
}
option.Some(Flash(msg, type_))
}
option.None -> option.None
}
}
pub fn view(flash: Option(Flash)) -> element.Element(t) {
case flash {
option.Some(Flash(msg, type_)) ->
h.div([a.class(flash_classes(type_))], [element.text(msg)])
option.None -> element.none()
}
}

View File

@@ -0,0 +1,106 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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 lustre/attribute as a
import lustre/element
import lustre/element/html as h
pub fn compact_info(label: String, value: String) {
h.div([], [
h.span([a.class("text-neutral-500")], [element.text(label <> ": ")]),
h.span([a.class("text-neutral-900")], [element.text(value)]),
])
}
pub fn compact_info_mono(label: String, value: String) {
h.div([], [
h.span([a.class("text-neutral-500")], [element.text(label <> ": ")]),
h.span([a.class("text-neutral-900")], [element.text(value)]),
])
}
pub fn compact_info_with_element(label: String, value: element.Element(a)) {
h.div([], [
h.span([a.class("text-neutral-500")], [element.text(label <> ": ")]),
h.span([a.class("text-neutral-900")], [value]),
])
}
pub fn form_field(
label: String,
name: String,
type_: String,
placeholder: String,
required: Bool,
help: String,
) {
h.div([a.class("space-y-1")], [
h.label([a.class("text-sm text-neutral-700")], [
element.text(label),
]),
h.input([
a.type_(type_),
a.name(name),
a.placeholder(placeholder),
a.required(required),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900",
),
case type_ == "number" {
True -> a.attribute("step", "any")
False -> a.class("")
},
]),
h.p([a.class("text-xs text-neutral-500")], [element.text(help)]),
])
}
pub fn form_field_with_value(
label: String,
name: String,
type_: String,
value: String,
required: Bool,
help: String,
) {
h.div([a.class("space-y-1")], [
h.label([a.class("text-sm text-neutral-700")], [
element.text(label),
]),
h.input([
a.type_(type_),
a.name(name),
a.value(value),
a.required(required),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900",
),
case type_ == "number" {
True -> a.attribute("step", "any")
False -> a.class("")
},
]),
h.p([a.class("text-xs text-neutral-500")], [element.text(help)]),
])
}
pub fn info_item(label: String, value: String) {
h.div([], [
h.p([a.class("text-xs text-neutral-600")], [element.text(label)]),
h.p([a.class("text-sm text-neutral-900")], [element.text(value)]),
])
}

View File

@@ -0,0 +1,157 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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 lustre/attribute as a
import lustre/element
pub fn paperclip_icon(color: String) {
element.element(
"svg",
[
a.attribute("xmlns", "http://www.w3.org/2000/svg"),
a.attribute("viewBox", "0 0 256 256"),
a.class("w-3 h-3 inline-block " <> color),
],
[
element.element(
"rect",
[
a.attribute("width", "256"),
a.attribute("height", "256"),
a.attribute("fill", "none"),
],
[],
),
element.element(
"path",
[
a.attribute(
"d",
"M108.71,197.23l-5.11,5.11a46.63,46.63,0,0,1-66-.05h0a46.63,46.63,0,0,1,.06-65.89L72.4,101.66a46.62,46.62,0,0,1,65.94,0h0A46.34,46.34,0,0,1,150.78,124",
),
a.attribute("fill", "none"),
a.attribute("stroke", "currentColor"),
a.attribute("stroke-linecap", "round"),
a.attribute("stroke-linejoin", "round"),
a.attribute("stroke-width", "24"),
],
[],
),
element.element(
"path",
[
a.attribute(
"d",
"M147.29,58.77l5.11-5.11a46.62,46.62,0,0,1,65.94,0h0a46.62,46.62,0,0,1,0,65.94L193.94,144,183.6,154.34a46.63,46.63,0,0,1-66-.05h0A46.46,46.46,0,0,1,105.22,132",
),
a.attribute("fill", "none"),
a.attribute("stroke", "currentColor"),
a.attribute("stroke-linecap", "round"),
a.attribute("stroke-linejoin", "round"),
a.attribute("stroke-width", "24"),
],
[],
),
],
)
}
pub fn checkmark_icon(color: String) {
element.element(
"svg",
[
a.attribute("xmlns", "http://www.w3.org/2000/svg"),
a.attribute("viewBox", "0 0 256 256"),
a.class("w-4 h-4 inline-block " <> color),
],
[
element.element(
"rect",
[
a.attribute("width", "256"),
a.attribute("height", "256"),
a.attribute("fill", "none"),
],
[],
),
element.element(
"polyline",
[
a.attribute("points", "40 144 96 200 224 72"),
a.attribute("fill", "none"),
a.attribute("stroke", "currentColor"),
a.attribute("stroke-linecap", "round"),
a.attribute("stroke-linejoin", "round"),
a.attribute("stroke-width", "24"),
],
[],
),
],
)
}
pub fn x_icon(color: String) {
element.element(
"svg",
[
a.attribute("xmlns", "http://www.w3.org/2000/svg"),
a.attribute("viewBox", "0 0 256 256"),
a.class("w-4 h-4 inline-block " <> color),
],
[
element.element(
"rect",
[
a.attribute("width", "256"),
a.attribute("height", "256"),
a.attribute("fill", "none"),
],
[],
),
element.element(
"line",
[
a.attribute("x1", "200"),
a.attribute("y1", "56"),
a.attribute("x2", "56"),
a.attribute("y2", "200"),
a.attribute("fill", "none"),
a.attribute("stroke", "currentColor"),
a.attribute("stroke-linecap", "round"),
a.attribute("stroke-linejoin", "round"),
a.attribute("stroke-width", "24"),
],
[],
),
element.element(
"line",
[
a.attribute("x1", "200"),
a.attribute("y1", "200"),
a.attribute("x2", "56"),
a.attribute("y2", "56"),
a.attribute("fill", "none"),
a.attribute("stroke", "currentColor"),
a.attribute("stroke-linecap", "round"),
a.attribute("stroke-linejoin", "round"),
a.attribute("stroke-width", "24"),
],
[],
),
],
)
}

View File

@@ -0,0 +1,46 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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 lustre/attribute as a
import lustre/element.{type Element}
import lustre/element/html as h
pub fn build_icon_links(cdn_endpoint: String) -> List(Element(t)) {
[
h.link([
a.rel("icon"),
a.attribute("type", "image/x-icon"),
a.href(cdn_endpoint <> "/web/favicon.ico"),
]),
h.link([
a.rel("apple-touch-icon"),
a.href(cdn_endpoint <> "/web/apple-touch-icon.png"),
]),
h.link([
a.rel("icon"),
a.attribute("type", "image/png"),
a.attribute("sizes", "32x32"),
a.href(cdn_endpoint <> "/web/favicon-32x32.png"),
]),
h.link([
a.rel("icon"),
a.attribute("type", "image/png"),
a.attribute("sizes", "16x16"),
a.href(cdn_endpoint <> "/web/favicon-16x16.png"),
]),
]
}

View File

@@ -0,0 +1,392 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common.{type UserLookupResult}
import fluxer_admin/avatar
import fluxer_admin/components/flash
import fluxer_admin/components/icons_meta
import fluxer_admin/user
import fluxer_admin/web.{type Context, type Session, cache_busted_asset, href}
import gleam/list
import gleam/option.{type Option}
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
pub fn build_head(title: String, ctx: Context) -> element.Element(a) {
build_head_with_refresh(title, ctx, False)
}
pub fn build_head_with_refresh(
title: String,
ctx: Context,
auto_refresh: Bool,
) -> element.Element(a) {
let refresh_meta = case auto_refresh {
True -> [
h.meta([a.attribute("http-equiv", "refresh"), a.attribute("content", "3")]),
]
False -> []
}
h.head([], [
h.meta([a.attribute("charset", "UTF-8")]),
h.meta([
a.attribute("name", "viewport"),
a.attribute("content", "width=device-width, initial-scale=1.0"),
]),
..list.append(
refresh_meta,
list.append(
[
h.title([], title <> " ~ Fluxer Admin"),
h.link([
a.rel("stylesheet"),
a.href(cache_busted_asset(ctx, "/static/app.css")),
]),
],
icons_meta.build_icon_links(ctx.cdn_endpoint),
),
)
])
}
pub fn page(
title: String,
active_page: String,
ctx: Context,
session: Session,
current_admin: Option(UserLookupResult),
flash_data: Option(flash.Flash),
content: element.Element(a),
) {
page_with_refresh(
title,
active_page,
ctx,
session,
current_admin,
flash_data,
content,
False,
)
}
pub fn page_with_refresh(
title: String,
active_page: String,
ctx: Context,
session: Session,
current_admin: Option(UserLookupResult),
flash_data: Option(flash.Flash),
content: element.Element(a),
auto_refresh: Bool,
) {
h.html(
[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,
]),
]),
]),
]),
],
)
}
fn sidebar(ctx: Context, active_page: String) {
h.div(
[
a.class(
"w-64 bg-neutral-900 text-white flex flex-col h-screen fixed left-0 top-0",
),
],
[
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.nav(
[
a.class("flex-1 overflow-y-auto p-4 space-y-1 sidebar-scrollbar"),
],
admin_sidebar(ctx, active_page),
),
h.script(
[a.attribute("defer", "defer")],
"(function(){var el=document.querySelector('[data-active]');if(el)el.scrollIntoView({block:'nearest'});})();",
),
],
)
}
fn admin_sidebar(ctx: Context, active_page: String) -> List(element.Element(a)) {
[
sidebar_section("Lookup", [
sidebar_item(ctx, "Users", "/users", active_page == "users"),
sidebar_item(ctx, "Guilds", "/guilds", active_page == "guilds"),
]),
sidebar_section("Moderation", [
sidebar_item(ctx, "Reports", "/reports", active_page == "reports"),
sidebar_item(
ctx,
"Pending Verifications",
"/pending-verifications",
active_page == "pending-verifications",
),
sidebar_item(
ctx,
"Bulk Actions",
"/bulk-actions",
active_page == "bulk-actions",
),
]),
sidebar_section("Bans", [
sidebar_item(ctx, "IP Bans", "/ip-bans", active_page == "ip-bans"),
sidebar_item(
ctx,
"Email Bans",
"/email-bans",
active_page == "email-bans",
),
sidebar_item(
ctx,
"Phone Bans",
"/phone-bans",
active_page == "phone-bans",
),
]),
sidebar_section("Content", [
sidebar_item(
ctx,
"Message Tools",
"/messages",
active_page == "message-tools",
),
sidebar_item(ctx, "Archives", "/archives", active_page == "archives"),
sidebar_item(
ctx,
"Asset Purge",
"/asset-purge",
active_page == "asset-purge",
),
]),
sidebar_section("Metrics", [
sidebar_item(ctx, "Overview", "/metrics", active_page == "metrics"),
sidebar_item(
ctx,
"Messaging & API",
"/messages-metrics",
active_page == "messages-metrics",
),
]),
sidebar_section("Observability", [
sidebar_item(ctx, "Gateway", "/gateway", active_page == "gateway"),
sidebar_item(ctx, "Jobs", "/jobs", active_page == "jobs"),
sidebar_item(ctx, "Storage", "/storage", active_page == "storage"),
sidebar_item(
ctx,
"Audit Logs",
"/audit-logs",
active_page == "audit-logs",
),
]),
sidebar_section("Platform", [
sidebar_item(
ctx,
"Search Index",
"/search-index",
active_page == "search-index",
),
sidebar_item(
ctx,
"Voice Regions",
"/voice-regions",
active_page == "voice-regions",
),
sidebar_item(
ctx,
"Voice Servers",
"/voice-servers",
active_page == "voice-servers",
),
]),
sidebar_section("Configuration", [
sidebar_item(
ctx,
"Instance Config",
"/instance-config",
active_page == "instance-config",
),
sidebar_item(
ctx,
"Feature Flags",
"/feature-flags",
active_page == "feature-flags",
),
]),
sidebar_section("Codes", [
sidebar_item(
ctx,
"Beta Codes",
"/beta-codes",
active_page == "beta-codes",
),
sidebar_item(
ctx,
"Gift Codes",
"/gift-codes",
active_page == "gift-codes",
),
]),
]
}
fn sidebar_section(title: String, items: List(element.Element(a))) {
h.div([a.class("mb-4")], [
h.div([a.class("text-neutral-400 text-xs uppercase mb-2")], [
element.text(title),
]),
h.div([a.class("space-y-1")], items),
])
}
fn sidebar_item(ctx: Context, title: String, path: String, active: Bool) {
let classes = case active {
True ->
"block px-3 py-2 rounded bg-neutral-800 text-white text-sm transition-colors"
False ->
"block px-3 py-2 rounded text-neutral-300 hover:bg-neutral-800 hover:text-white text-sm transition-colors"
}
let attrs = case active {
True -> [href(ctx, path), a.class(classes), a.attribute("data-active", "")]
False -> [href(ctx, path), a.class(classes)]
}
h.a(attrs, [element.text(title)])
}
fn header(
ctx: Context,
session: Session,
current_admin: Option(UserLookupResult),
) {
h.header(
[
a.class(
"bg-white border-b border-neutral-200 px-8 py-4 flex items-center justify-between",
),
],
[
render_user_info(ctx, session, current_admin),
h.a(
[
href(ctx, "/logout"),
a.class(
"px-4 py-2 text-sm font-medium text-neutral-700 hover:text-neutral-900 border border-neutral-300 rounded hover:border-neutral-400 transition-colors",
),
],
[element.text("Logout")],
),
],
)
}
fn render_user_info(
ctx: Context,
session: Session,
current_admin: Option(UserLookupResult),
) {
case current_admin {
option.Some(admin_user) -> {
h.a(
[
href(ctx, "/users/" <> session.user_id),
a.class("flex items-center gap-3 hover:opacity-80 transition-opacity"),
],
[
render_avatar(
ctx,
admin_user.id,
admin_user.avatar,
admin_user.username,
),
h.div([a.class("flex flex-col")], [
h.div([a.class("text-sm text-neutral-900")], [
element.text(
admin_user.username
<> "#"
<> user.format_discriminator(admin_user.discriminator),
),
]),
h.div([a.class("text-xs text-neutral-500")], [
element.text("Admin"),
]),
]),
],
)
}
option.None -> {
h.div([a.class("text-sm text-neutral-600")], [
element.text("Logged in as: "),
h.a(
[
href(ctx, "/users/" <> session.user_id),
a.class("text-blue-600 hover:text-blue-800 hover:underline"),
],
[element.text(session.user_id)],
),
])
}
}
}
fn render_avatar(
ctx: Context,
user_id: String,
avatar: Option(String),
username: String,
) {
h.img([
a.src(avatar.get_user_avatar_url(
ctx.media_endpoint,
ctx.cdn_endpoint,
user_id,
avatar,
True,
ctx.asset_version,
)),
a.alt(username <> "'s avatar"),
a.class("w-10 h-10 rounded-full"),
])
}

View File

@@ -0,0 +1,180 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/messages
import fluxer_admin/components/icons
import fluxer_admin/web.{type Context, href}
import gleam/list
import gleam/string
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
pub fn render(
ctx: Context,
messages: List(messages.Message),
include_delete_button: Bool,
) {
h.div([a.class("space-y-1")], {
list.map(messages, fn(message) {
render_message_row(ctx, message, include_delete_button)
})
})
}
fn render_message_row(
ctx: Context,
message: messages.Message,
include_delete_button: Bool,
) {
h.div(
[
a.class(
"group flex items-start gap-3 px-4 py-2 hover:bg-neutral-50 transition-colors",
),
a.attribute("data-message-id", message.id),
],
[
h.div([a.class("flex-shrink-0 pt-0.5")], [
h.a(
[
href(ctx, "/users/" <> message.author_id),
a.class("text-xs text-neutral-900 hover:underline cursor-pointer"),
a.title(message.author_id),
],
[element.text(message.author_username)],
),
h.div([a.class("text-xs text-neutral-500")], [
element.text(message.timestamp),
]),
]),
h.div([a.class("flex-1 min-w-0 message-content")], [
h.div(
[a.class("text-sm text-neutral-900 whitespace-pre-wrap break-words")],
[element.text(message.content)],
),
case list.is_empty(message.attachments) {
True -> element.none()
False ->
h.div([a.class("mt-2 space-y-1")], {
list.map(message.attachments, fn(att) {
h.div([a.class("text-xs flex items-center gap-1")], [
icons.paperclip_icon("text-neutral-500"),
h.a(
[
a.href(att.url),
a.target("_blank"),
a.class("text-blue-600 hover:underline"),
],
[element.text(att.filename)],
),
])
})
})
},
h.div([a.class("text-xs text-neutral-400 mt-1")], [
element.text("ID: " <> message.id),
]),
]),
case include_delete_button && !string.is_empty(message.channel_id) {
True ->
h.div(
[
a.class(
"flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity",
),
],
[
h.button(
[
a.type_("button"),
a.class(
"px-2 py-1 text-xs text-red-600 hover:text-red-700 hover:bg-red-50 rounded transition-colors",
),
a.title("Delete message"),
a.attribute(
"onclick",
"deleteMessage('"
<> message.channel_id
<> "', '"
<> message.id
<> "', this)",
),
],
[element.text("Delete")],
),
],
)
False -> element.none()
},
],
)
}
pub fn deletion_script() {
"<script>
function deleteMessage(channelId, messageId, button) {
if (!confirm('Are you sure you want to delete this message?')) {
return;
}
const formData = new FormData();
formData.append('channel_id', channelId);
formData.append('message_id', messageId);
button.disabled = true;
button.textContent = 'Deleting...';
const basePath = document.documentElement.dataset.basePath || '';
fetch(basePath + '/messages?action=delete', {
method: 'POST',
body: formData
})
.then(response => {
if (response.ok) {
const messageRow = button.closest('[data-message-id]');
if (messageRow) {
messageRow.style.opacity = '0.5';
messageRow.style.pointerEvents = 'none';
const messageContent = messageRow.querySelector('.message-content');
if (messageContent) {
messageContent.style.textDecoration = 'line-through';
}
}
const buttonContainer = button.parentElement;
const deletedBadge = document.createElement('span');
deletedBadge.className = 'px-2 py-1 bg-red-100 text-red-800 text-xs rounded opacity-100';
deletedBadge.textContent = 'DELETED';
button.replaceWith(deletedBadge);
if (buttonContainer) {
buttonContainer.style.opacity = '1';
}
} else {
button.disabled = false;
button.textContent = 'Delete';
alert('Failed to delete message');
}
})
.catch(error => {
console.error('Error:', error);
button.disabled = false;
button.textContent = 'Delete';
alert('Error deleting message');
});
}
</script>"
}

View File

@@ -0,0 +1,93 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/web.{type Context, href}
import gleam/int
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
pub fn pagination(
ctx: Context,
total: Int,
limit: Int,
current_page: Int,
build_url_fn: fn(Int) -> String,
) -> element.Element(a) {
let total_pages = { total + limit - 1 } / limit
let has_previous = current_page > 0
let has_next = current_page < total_pages - 1
h.div([a.class("mt-6 flex justify-center gap-3 items-center")], [
case has_previous {
True -> {
let prev_url = build_url_fn(current_page - 1)
h.a(
[
href(ctx, prev_url),
a.class(
"px-6 py-2 bg-white text-neutral-900 border border-neutral-300 rounded-lg text-sm font-medium hover:bg-neutral-50 transition-colors",
),
],
[element.text("← Previous")],
)
}
False ->
h.div(
[
a.class(
"px-6 py-2 bg-neutral-100 text-neutral-400 border border-neutral-200 rounded-lg text-sm font-medium cursor-not-allowed",
),
],
[element.text("← Previous")],
)
},
h.span([a.class("text-sm text-neutral-600")], [
element.text(
"Page "
<> int.to_string(current_page + 1)
<> " of "
<> int.to_string(total_pages),
),
]),
case has_next {
True -> {
let next_url = build_url_fn(current_page + 1)
h.a(
[
href(ctx, next_url),
a.class(
"px-6 py-2 bg-neutral-900 text-white rounded-lg text-sm font-medium hover:bg-neutral-800 transition-colors no-underline",
),
],
[element.text("Next →")],
)
}
False ->
h.div(
[
a.class(
"px-6 py-2 bg-neutral-100 text-neutral-400 rounded-lg text-sm font-medium cursor-not-allowed",
),
],
[element.text("Next →")],
)
},
])
}

View File

@@ -0,0 +1,419 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/components/review_keyboard
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
pub fn styles() -> element.Element(a) {
let css =
"
[data-review-deck] { position: relative; }
[data-review-card] { will-change: transform, opacity; touch-action: pan-y; }
[data-review-card][hidden] { display: none !important; }
.review-card-enter { animation: reviewEnter 120ms ease-out; }
@keyframes reviewEnter { from { opacity: .6; transform: translateY(6px) scale(.995);} to { opacity: 1; transform: translateY(0) scale(1);} }
.review-card-leave-left { animation: reviewLeaveLeft 180ms ease-in forwards; }
.review-card-leave-right { animation: reviewLeaveRight 180ms ease-in forwards; }
@keyframes reviewLeaveLeft { to { opacity: 0; transform: translateX(-120%) rotate(-10deg);} }
@keyframes reviewLeaveRight { to { opacity: 0; transform: translateX(120%) rotate(10deg);} }
.review-toast { position: fixed; left: 16px; right: 16px; bottom: 16px; z-index: 80; }
.review-toast-inner { max-width: 720px; margin: 0 auto; }
.review-hintbar { position: sticky; bottom: 0; z-index: 10; }
.review-kbd { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; }
"
h.style([a.type_("text/css")], css)
}
pub fn script_tags() -> List(element.Element(a)) {
[
review_keyboard.script_tag(),
h.script([a.attribute("defer", "defer")], script()),
]
}
pub fn script() -> String {
"
(function () {
function qs(el, sel) { return el.querySelector(sel); }
function qsa(el, sel) { return Array.prototype.slice.call(el.querySelectorAll(sel)); }
function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); }
function showToast(message) {
var toast = document.createElement('div');
toast.className = 'review-toast';
toast.innerHTML =
'<div class=\"review-toast-inner\">' +
'<div class=\"bg-red-50 border border-red-200 text-red-800 rounded-xl px-4 py-3 shadow-lg\">' +
'<div class=\"text-sm font-semibold\">Action failed</div>' +
'<div class=\"text-sm mt-1\" style=\"word-break: break-word;\">' + (message || 'Unknown error') + '</div>' +
'</div>' +
'</div>';
document.body.appendChild(toast);
setTimeout(function () { toast.remove(); }, 4200);
}
function parseHTML(html) {
var parser = new DOMParser();
return parser.parseFromString(html, 'text/html');
}
function asURL(url) {
try { return new URL(url, window.location.origin); } catch (_) { return null; }
}
function enhanceDeck(deck) {
var cards = qsa(deck, '[data-review-card]');
var idx = 0;
var fragmentBase = deck.getAttribute('data-fragment-base') || '';
var nextPage = parseInt(deck.getAttribute('data-next-page') || '0', 10);
var canPaginate = deck.getAttribute('data-can-paginate') === 'true';
var prefetchWhenRemaining = parseInt(deck.getAttribute('data-prefetch-when-remaining') || '6', 10);
var prefetchInFlight = false;
var emptyUrl = deck.getAttribute('data-empty-url') || '';
function currentCard() { return cards[idx] || null; }
function remainingCount() { return Math.max(0, cards.length - idx); }
function setHiddenAllExcept(active) {
for (var i = 0; i < cards.length; i++) {
var c = cards[i];
c.hidden = (c !== active);
}
}
function updateUrlFor(card) {
var directUrl = card && card.getAttribute('data-direct-url');
if (!directUrl) return;
try {
history.replaceState({ review: true, directUrl: directUrl }, '', directUrl);
} catch (_) {}
}
function focusCard(card) {
if (!card) return;
requestAnimationFrame(function () {
try { card.focus({ preventScroll: true }); } catch (_) { try { card.focus(); } catch (_) {} }
});
}
function ensureActiveCard() {
var card = currentCard();
if (!card) {
if (emptyUrl) {
try { history.replaceState({}, '', emptyUrl); } catch (_) {}
}
deck.dispatchEvent(new CustomEvent('review:empty'));
return;
}
setHiddenAllExcept(card);
card.classList.remove('review-card-leave-left', 'review-card-leave-right');
card.classList.add('review-card-enter');
setTimeout(function () { card.classList.remove('review-card-enter'); }, 160);
updateUrlFor(card);
focusCard(card);
maybePrefetchDetails(card);
maybePrefetchMore();
updateProgress();
}
function updateProgress() {
var el = qs(deck, '[data-review-progress]');
if (!el) return;
el.textContent = remainingCount().toString() + ' remaining';
}
async function backgroundSubmit(form) {
var actionUrl = asURL(form.action);
if (!actionUrl) throw new Error('Invalid action URL');
actionUrl.searchParams.set('background', '1');
var fd = new FormData(form);
var body = new URLSearchParams();
fd.forEach(function (v, k) { body.append(k, v); });
var 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.status === 204) return;
var text = '';
try { text = await resp.text(); } catch (_) {}
if (!resp.ok) throw new Error(text || ('HTTP ' + resp.status));
}
function advance() {
idx = idx + 1;
ensureActiveCard();
}
function animateAndAdvance(card, dir) {
card.classList.remove('review-card-enter');
card.classList.add(dir === 'left' ? 'review-card-leave-left' : 'review-card-leave-right');
setTimeout(function () { advance(); }, 190);
}
async function act(dir) {
var card = currentCard();
if (!card) return;
if (dir === 'left' && card.getAttribute('data-left-mode') === 'skip') {
animateAndAdvance(card, 'left');
return;
}
var form = qs(card, 'form[data-review-submit=\"' + dir + '\"]');
if (!form) {
animateAndAdvance(card, dir);
return;
}
try {
await backgroundSubmit(form);
animateAndAdvance(card, dir);
} catch (err) {
showToast((err && err.message) ? err.message : String(err));
}
}
function onKeyDown(e) {
var t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
if (e.key === 'Escape' && emptyUrl) {
try { history.replaceState({}, '', emptyUrl); } catch (_) {}
}
}
function onKeyboardAction(e) {
var dir = e.detail && e.detail.direction;
if (dir === 'left' || dir === 'right') {
act(dir);
}
}
function wireButtons(card) {
var leftBtn = qs(card, '[data-review-action=\"left\"]');
var rightBtn = qs(card, '[data-review-action=\"right\"]');
if (leftBtn) {
leftBtn.addEventListener('click', function (e) {
e.preventDefault();
if (window.fluxerReviewKeyboard) {
window.fluxerReviewKeyboard.enable(deck);
}
act('left');
}, { capture: true });
}
if (rightBtn) {
rightBtn.addEventListener('click', function (e) {
e.preventDefault();
if (window.fluxerReviewKeyboard) {
window.fluxerReviewKeyboard.enable(deck);
}
act('right');
}, { capture: true });
}
var forms = qsa(card, 'form[data-review-submit]');
forms.forEach(function (f) {
f.addEventListener('submit', function (e) {
e.preventDefault();
var dir = f.getAttribute('data-review-submit');
if (dir === 'left' || dir === 'right') {
if (window.fluxerReviewKeyboard) {
window.fluxerReviewKeyboard.enable(deck);
}
}
act(dir);
}, { capture: true });
});
}
function wireAll() { cards.forEach(wireButtons); }
function wireSwipe(card) {
var tracking = null;
card.addEventListener('pointerdown', function (e) {
if (e.button != null && e.button !== 0) return;
tracking = {
id: e.pointerId,
startX: e.clientX,
startY: e.clientY,
x: 0,
y: 0,
moved: false
};
try { card.setPointerCapture(e.pointerId); } catch (_) {}
});
card.addEventListener('pointermove', function (e) {
if (!tracking || tracking.id !== e.pointerId) return;
tracking.x = e.clientX - tracking.startX;
tracking.y = e.clientY - tracking.startY;
if (!tracking.moved) {
if (Math.abs(tracking.y) > 12 && Math.abs(tracking.y) > Math.abs(tracking.x)) {
tracking = null;
card.style.transform = '';
return;
}
tracking.moved = true;
}
var w = Math.max(320, card.getBoundingClientRect().width);
var pct = clamp(tracking.x / w, -1, 1);
var rot = pct * 8;
card.style.transform = 'translateX(' + tracking.x + 'px) rotate(' + rot + 'deg)';
card.style.opacity = String(1 - Math.min(0.35, Math.abs(pct) * 0.35));
});
function endSwipe(e) {
if (!tracking || tracking.id !== e.pointerId) return;
var dx = tracking.x;
tracking = null;
var w = Math.max(320, card.getBoundingClientRect().width);
var threshold = Math.max(110, w * 0.22);
if (Math.abs(dx) >= threshold) {
card.style.transform = '';
card.style.opacity = '';
var dir = dx < 0 ? 'left' : 'right';
act(dir);
return;
}
card.style.transition = 'transform 120ms ease-out, opacity 120ms ease-out';
card.style.transform = '';
card.style.opacity = '';
setTimeout(function () { card.style.transition = ''; }, 140);
}
card.addEventListener('pointerup', endSwipe);
card.addEventListener('pointercancel', endSwipe);
}
function wireSwipeAll() { cards.forEach(wireSwipe); }
async function maybePrefetchMore() {
if (!canPaginate) return;
if (!fragmentBase) return;
if (prefetchInFlight) return;
if (remainingCount() > prefetchWhenRemaining) return;
prefetchInFlight = true;
try {
var url = asURL(fragmentBase);
if (!url) return;
url.searchParams.set('page', String(nextPage));
var resp = await fetch(url.toString(), { credentials: 'same-origin' });
if (!resp.ok) return;
var html = await resp.text();
var doc = parseHTML(html);
var frag = doc.querySelector('[data-review-fragment]');
if (!frag) return;
var newCards = Array.prototype.slice.call(frag.querySelectorAll('[data-review-card]'));
if (newCards.length === 0) return;
newCards.forEach(function (c) {
c.hidden = true;
deck.appendChild(c);
});
cards = qsa(deck, '[data-review-card]');
newCards.forEach(function (c) { wireButtons(c); wireSwipe(c); });
nextPage = nextPage + 1;
} finally {
prefetchInFlight = false;
}
}
async function maybePrefetchDetails(card) {
var expandUrl = card.getAttribute('data-expand-url');
var targetSel = card.getAttribute('data-expand-target') || '';
if (!expandUrl || !targetSel) return;
if (card.getAttribute('data-expanded') === 'true') return;
var target = qs(card, targetSel);
if (!target) return;
card.setAttribute('data-expanded', 'inflight');
try {
var resp = await fetch(expandUrl, { credentials: 'same-origin' });
if (!resp.ok) { card.setAttribute('data-expanded', 'false'); return; }
var html = await resp.text();
var doc = parseHTML(html);
var frag = doc.querySelector('[data-report-fragment]');
if (!frag) { card.setAttribute('data-expanded', 'false'); return; }
target.innerHTML = '';
target.appendChild(frag);
target.hidden = false;
card.setAttribute('data-expanded', 'true');
} catch (_) {
card.setAttribute('data-expanded', 'false');
}
}
function wireExpandButtons() {
cards.forEach(function (card) {
var btn = qs(card, '[data-review-expand]');
if (!btn) return;
btn.addEventListener('click', function (e) {
e.preventDefault();
card.setAttribute('data-expanded', 'false');
maybePrefetchDetails(card);
});
});
}
deck.addEventListener('keydown', onKeyDown);
deck.addEventListener('review:keyboard', onKeyboardAction);
wireAll();
wireSwipeAll();
wireExpandButtons();
requestAnimationFrame(function () {
ensureActiveCard();
});
}
function init() {
var decks = document.querySelectorAll('[data-review-deck]');
for (var i = 0; i < decks.length; i++) {
enhanceDeck(decks[i]);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
"
}

View File

@@ -0,0 +1,91 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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 gleam/option
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
pub fn view(
left_key: String,
left_label: String,
right_key: String,
right_label: String,
exit_key: String,
exit_label: String,
note: option.Option(String),
) {
let note_element = case note {
option.Some(text) ->
h.div([a.class("body-sm text-neutral-600")], [element.text(text)])
option.None -> element.none()
}
h.div(
[
a.class(
"review-hintbar mt-6 p-4 bg-neutral-50 border-t border-neutral-200",
),
],
[
h.div([a.class("max-w-7xl mx-auto flex items-center justify-between")], [
h.div([a.class("flex gap-6 items-center")], [
h.div([a.class("flex items-center gap-2")], [
h.span(
[
a.class(
"review-kbd px-2 py-1 bg-white border border-neutral-300 rounded text-xs",
),
],
[element.text(left_key)],
),
h.span([a.class("body-sm text-neutral-700")], [
element.text(left_label),
]),
]),
h.div([a.class("flex items-center gap-2")], [
h.span(
[
a.class(
"review-kbd px-2 py-1 bg-white border border-neutral-300 rounded text-xs",
),
],
[element.text(right_key)],
),
h.span([a.class("body-sm text-neutral-700")], [
element.text(right_label),
]),
]),
h.div([a.class("flex items-center gap-2")], [
h.span(
[
a.class(
"review-kbd px-2 py-1 bg-white border border-neutral-300 rounded text-xs",
),
],
[element.text(exit_key)],
),
h.span([a.class("body-sm text-neutral-700")], [
element.text(exit_label),
]),
]),
]),
note_element,
]),
],
)
}

View File

@@ -0,0 +1,101 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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 lustre/attribute as a
import lustre/element
import lustre/element/html as h
pub fn script_tag() -> element.Element(a) {
h.script([a.attribute("defer", "defer")], script())
}
pub fn script() -> String {
"
(function () {
var globalKeyboardMode = false;
var activeDeck = null;
function isEditable(el) {
if (!el) return false;
var tag = el.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
return el.isContentEditable;
}
function triggerAction(deck, direction) {
if (!deck) return;
var event = new CustomEvent('review:keyboard', {
detail: { direction: direction },
bubbles: true,
cancelable: true
});
deck.dispatchEvent(event);
}
function onGlobalKeyDown(e) {
if (!globalKeyboardMode) return;
if (!activeDeck) return;
if (isEditable(e.target)) return;
if (e.key === 'ArrowLeft') {
e.preventDefault();
e.stopPropagation();
triggerAction(activeDeck, 'left');
return;
}
if (e.key === 'ArrowRight') {
e.preventDefault();
e.stopPropagation();
triggerAction(activeDeck, 'right');
return;
}
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
disableKeyboardMode();
return;
}
}
function enableKeyboardMode(deckElement) {
if (!deckElement) return;
activeDeck = deckElement;
globalKeyboardMode = true;
deckElement.setAttribute('data-keyboard-mode', 'true');
}
function disableKeyboardMode() {
if (activeDeck) {
activeDeck.removeAttribute('data-keyboard-mode');
}
globalKeyboardMode = false;
activeDeck = null;
}
window.fluxerReviewKeyboard = {
enable: enableKeyboardMode,
disable: disableKeyboardMode,
isEnabled: function () { return globalKeyboardMode; },
getActiveDeck: function () { return activeDeck; }
};
document.addEventListener('keydown', onGlobalKeyDown, { capture: true });
})();
"
}

View File

@@ -0,0 +1,99 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/components/ui
import fluxer_admin/web.{type Context, href}
import gleam/list
import gleam/option.{type Option}
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
pub fn search_form(
ctx: Context,
query: Option(String),
placeholder: String,
help_text: Option(String),
clear_url: String,
additional_filters: List(element.Element(a)),
) -> element.Element(a) {
ui.card(ui.PaddingSmall, [
h.form([a.method("get"), a.class("flex flex-col gap-4")], [
case list.is_empty(additional_filters) {
True ->
h.div([a.class("flex gap-2")], [
h.input([
a.type_("text"),
a.name("q"),
a.value(option.unwrap(query, "")),
a.placeholder(placeholder),
a.class(
"flex-1 px-4 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:border-transparent",
),
a.attribute("autocomplete", "off"),
]),
ui.button_primary("Search", "submit", []),
h.a(
[
href(ctx, clear_url),
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",
),
],
[element.text("Clear")],
),
])
False ->
h.div([a.class("flex flex-col gap-4")], [
h.div([a.class("flex gap-2")], [
h.input([
a.type_("text"),
a.name("q"),
a.value(option.unwrap(query, "")),
a.placeholder(placeholder),
a.class(
"flex-1 px-4 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:border-transparent",
),
a.attribute("autocomplete", "off"),
]),
]),
h.div(
[a.class("grid grid-cols-1 md:grid-cols-4 gap-4")],
additional_filters,
),
h.div([a.class("flex gap-2")], [
ui.button_primary("Search", "submit", []),
h.a(
[
href(ctx, clear_url),
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",
),
],
[element.text("Clear")],
),
]),
])
},
case help_text {
option.Some(text) ->
h.p([a.class("text-xs text-neutral-500")], [element.text(text)])
option.None -> element.none()
},
]),
])
}

View File

@@ -0,0 +1,64 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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 gleam/int
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
pub fn range_slider_section(
slider_id: String,
value_id: String,
min_value: Int,
max_value: Int,
current_value: Int,
) {
[
h.input([
a.id(slider_id),
a.type_("range"),
a.name("count"),
a.min(int.to_string(min_value)),
a.max(int.to_string(max_value)),
a.value(int.to_string(current_value)),
a.class("w-full h-2 bg-neutral-200 rounded-lg accent-neutral-900"),
]),
h.div(
[a.class("flex items-baseline justify-between text-xs text-neutral-500")],
[
h.span([], [element.text("Selected amount")]),
h.span([a.id(value_id), a.class("font-semibold text-neutral-900")], [
element.text(int.to_string(current_value)),
]),
],
),
]
}
pub fn slider_sync_script(
slider_id: String,
value_id: String,
) -> element.Element(a) {
let script =
"(function(){const slider=document.getElementById('"
<> slider_id
<> "');const value=document.getElementById('"
<> value_id
<> "');if(!slider||!value)return;const update=()=>value.textContent=slider.value;update();slider.addEventListener('input',update);})();"
h.script([a.attribute("defer", "defer")], script)
}

View File

@@ -0,0 +1,45 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/web.{type Context, href}
import gleam/list
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
pub type Tab {
Tab(label: String, path: String, active: Bool)
}
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")],
list.map(tabs, fn(tab) { render_tab(ctx, tab) }),
),
])
}
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"
False ->
"border-b-2 border-transparent text-neutral-600 hover:text-neutral-900 hover:border-neutral-300 text-sm pb-3 transition-colors"
}
h.a([href(ctx, tab.path), a.class(class_active)], [element.text(tab.label)])
}

View File

@@ -0,0 +1,662 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/web.{type Context, href}
import gleam/int
import gleam/list
import gleam/option.{type Option}
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_header_cell_class = "px-6 py-3 text-left text-xs text-neutral-600 uppercase tracking-wider"
pub const table_cell_class = "px-6 py-4 text-sm text-neutral-900"
pub const table_cell_muted_class = "px-6 py-4 text-sm text-neutral-600"
pub fn table_container(children: List(element.Element(a))) -> element.Element(a) {
h.div([a.class(table_container_class)], children)
}
pub fn table_header_cell(label: String) -> element.Element(a) {
h.th([a.class(table_header_cell_class)], [element.text(label)])
}
pub type PillTone {
PillNeutral
PillInfo
PillSuccess
PillWarning
PillDanger
PillPrimary
PillPurple
PillOrange
}
pub fn pill(label: String, tone: PillTone) -> element.Element(a) {
h.span(
[
a.class(
"inline-flex items-center px-2 py-1 rounded text-xs font-medium "
<> pill_classes(tone),
),
],
[element.text(label)],
)
}
fn pill_classes(tone: PillTone) -> String {
case tone {
PillNeutral -> "bg-neutral-100 text-neutral-700"
PillInfo -> "bg-blue-100 text-blue-700"
PillSuccess -> "bg-green-100 text-green-700"
PillWarning -> "bg-yellow-100 text-yellow-700"
PillDanger -> "bg-red-100 text-red-700"
PillPrimary -> "bg-neutral-900 text-white"
PillPurple -> "bg-purple-100 text-purple-700"
PillOrange -> "bg-orange-100 text-orange-700"
}
}
pub type ButtonSize {
Small
Medium
Large
}
pub type ButtonVariant {
Primary
Secondary
Danger
Success
Info
Ghost
}
pub type ButtonWidth {
Auto
Full
}
pub fn button_primary(
text: String,
type_: String,
attrs: List(a.Attribute(msg)),
) -> element.Element(msg) {
button(text, type_, Primary, Medium, Auto, attrs)
}
pub fn button_danger(
text: String,
type_: String,
attrs: List(a.Attribute(msg)),
) -> element.Element(msg) {
button(text, type_, Danger, Medium, Auto, attrs)
}
pub fn button_success(
text: String,
type_: String,
attrs: List(a.Attribute(msg)),
) -> element.Element(msg) {
button(text, type_, Success, Medium, Auto, attrs)
}
pub fn button_info(
text: String,
type_: String,
attrs: List(a.Attribute(msg)),
) -> element.Element(msg) {
button(text, type_, Info, Medium, Auto, attrs)
}
pub fn button_secondary(
text: String,
type_: String,
attrs: List(a.Attribute(msg)),
) -> element.Element(msg) {
button(text, type_, Secondary, Medium, Auto, attrs)
}
pub fn button(
text: String,
type_: String,
variant: ButtonVariant,
size: ButtonSize,
width: ButtonWidth,
extra_attrs: List(a.Attribute(msg)),
) -> element.Element(msg) {
let base_classes = "text-sm font-medium rounded-lg transition-colors"
let size_classes = case size {
Small -> "px-3 py-1.5 text-sm"
Medium -> "px-4 py-2"
Large -> "px-6 py-3 text-base"
}
let width_classes = case width {
Auto -> ""
Full -> "w-full"
}
let variant_classes = case variant {
Primary -> "bg-neutral-900 text-white hover:bg-neutral-800"
Secondary ->
"text-neutral-700 hover:text-neutral-900 border border-neutral-300 hover:border-neutral-400"
Danger -> "bg-red-600 text-white hover:bg-red-700"
Success -> "bg-blue-600 text-white hover:bg-blue-700"
Info -> "bg-blue-50 text-blue-700 hover:bg-blue-100"
Ghost -> "text-neutral-600 hover:text-neutral-900 hover:bg-neutral-100"
}
let classes =
[base_classes, size_classes, width_classes, variant_classes]
|> list.filter(fn(c) { c != "" })
|> list.fold("", fn(acc, c) { acc <> " " <> c })
|> string_trim
let attrs = [a.type_(type_), a.class(classes), ..extra_attrs]
h.button(attrs, [element.text(text)])
}
@external(erlang, "string", "trim")
fn string_trim(string: String) -> String
pub type InputType {
Text
Email
Password
Tel
Number
Date
Url
}
fn input_type_to_string(input_type: InputType) -> String {
case input_type {
Text -> "text"
Email -> "email"
Password -> "password"
Tel -> "tel"
Number -> "number"
Date -> "date"
Url -> "url"
}
}
pub fn input(
label: String,
name: String,
input_type: InputType,
value: Option(String),
required: Bool,
placeholder: Option(String),
) -> element.Element(a) {
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm text-neutral-700")], [
element.text(label),
]),
input_field(name, input_type, value, required, placeholder),
])
}
pub fn input_field(
name: String,
input_type: InputType,
value: Option(String),
required: Bool,
placeholder: Option(String),
) -> element.Element(a) {
let base_attrs = [
a.type_(input_type_to_string(input_type)),
a.name(name),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900",
),
]
let value_attr = case value {
option.Some(v) -> [a.value(v)]
option.None -> []
}
let required_attr = case required {
True -> [a.required(True)]
False -> []
}
let placeholder_attr = case placeholder {
option.Some(p) -> [a.placeholder(p)]
option.None -> []
}
let attrs =
list.flatten([base_attrs, value_attr, required_attr, placeholder_attr])
h.input(attrs)
}
pub fn textarea(
label: String,
name: String,
value: Option(String),
required: Bool,
placeholder: Option(String),
rows: Int,
) -> element.Element(a) {
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm text-neutral-700")], [
element.text(label),
]),
textarea_field(name, value, required, placeholder, rows),
])
}
pub fn textarea_field(
name: String,
value: Option(String),
required: Bool,
placeholder: Option(String),
rows: Int,
) -> element.Element(a) {
let base_attrs = [
a.name(name),
a.attribute("rows", int.to_string(rows)),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900",
),
]
let required_attr = case required {
True -> [a.required(True)]
False -> []
}
let placeholder_attr = case placeholder {
option.Some(p) -> [a.placeholder(p)]
option.None -> []
}
let value_text = option.unwrap(value, "")
let attrs = list.flatten([base_attrs, required_attr, placeholder_attr])
h.textarea(attrs, value_text)
}
pub type CardPadding {
PaddingNone
PaddingSmall
PaddingMedium
PaddingLarge
PaddingExtraLarge
}
pub fn card(
padding: CardPadding,
children: List(element.Element(a)),
) -> element.Element(a) {
let padding_class = case padding {
PaddingNone -> "p-0"
PaddingSmall -> "p-4"
PaddingMedium -> "p-6"
PaddingLarge -> "p-8"
PaddingExtraLarge -> "p-12"
}
h.div(
[a.class("bg-white border border-neutral-200 rounded-lg " <> padding_class)],
children,
)
}
pub fn card_elevated(
padding: CardPadding,
children: List(element.Element(a)),
) -> element.Element(a) {
let padding_class = case padding {
PaddingNone -> "p-0"
PaddingSmall -> "p-4"
PaddingMedium -> "p-6"
PaddingLarge -> "p-8"
PaddingExtraLarge -> "p-12"
}
h.div(
[
a.class(
"bg-white border border-neutral-200 rounded-lg shadow-sm "
<> padding_class,
),
],
children,
)
}
pub fn card_empty(children: List(element.Element(a))) -> element.Element(a) {
h.div(
[
a.class("bg-white border border-neutral-200 rounded-lg p-12 text-center"),
],
children,
)
}
pub fn heading_page(text: String) -> element.Element(a) {
h.h1([a.class("text-lg font-semibold text-neutral-900")], [element.text(text)])
}
pub fn heading_section(text: String) -> element.Element(a) {
h.h2([a.class("text-base font-semibold text-neutral-900")], [
element.text(text),
])
}
pub fn heading_card(text: String) -> element.Element(a) {
h.h3([a.class("text-base font-medium text-neutral-900")], [element.text(text)])
}
pub fn heading_card_with_margin(text: String) -> element.Element(a) {
h.div([a.class("mb-4")], [heading_card(text)])
}
pub fn text_muted(text: String) -> element.Element(a) {
h.p([a.class("text-sm text-neutral-600")], [element.text(text)])
}
pub fn text_small_muted(text: String) -> element.Element(a) {
h.p([a.class("text-xs text-neutral-500")], [element.text(text)])
}
pub fn detail_header(
title: String,
subtitle_items: List(#(String, element.Element(a))),
) -> element.Element(a) {
h.div([a.class("flex-1")], [
h.div([a.class("flex items-center gap-3 mb-3")], [
h.h1([a.class("text-base font-semibold text-neutral-900")], [
element.text(title),
]),
]),
h.div(
[a.class("flex flex-wrap items-start gap-4")],
list.map(subtitle_items, fn(item) {
let #(label, value) = item
h.div([a.class("flex items-start gap-2")], [
h.div([a.class("text-sm font-medium text-neutral-600")], [
element.text(label),
]),
value,
])
}),
),
])
}
pub fn info_item_text(label: String, value: String) -> element.Element(a) {
info_item(
label,
h.div([a.class("text-sm text-neutral-900")], [element.text(value)]),
)
}
pub fn info_item(label: String, value: element.Element(a)) -> element.Element(a) {
h.div([], [
h.div([a.class("text-sm font-medium text-neutral-600 mb-1")], [
element.text(label),
]),
value,
])
}
pub fn info_grid(items: List(element.Element(a))) -> element.Element(a) {
h.div([a.class("grid grid-cols-2 md:grid-cols-3 gap-x-6 gap-y-3")], items)
}
pub type BadgeVariant {
BadgeDefault
BadgeInfo
BadgeSuccess
BadgeWarning
BadgeDanger
}
pub fn badge(text: String, variant: BadgeVariant) -> element.Element(a) {
let variant_classes = case variant {
BadgeDefault -> "bg-neutral-100 text-neutral-700"
BadgeInfo -> "bg-blue-100 text-blue-700"
BadgeSuccess -> "bg-green-100 text-green-700"
BadgeWarning -> "bg-yellow-100 text-yellow-700"
BadgeDanger -> "bg-red-100 text-red-700"
}
h.span(
[
a.class(
"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs "
<> variant_classes,
),
],
[element.text(text)],
)
}
pub fn flex_row(
gap: String,
children: List(element.Element(a)),
) -> element.Element(a) {
h.div([a.class("flex items-center gap-" <> gap)], children)
}
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)
}
pub fn stack(
gap: String,
children: List(element.Element(a)),
) -> element.Element(a) {
h.div([a.class("space-y-" <> gap)], children)
}
pub fn grid(
cols: String,
gap: String,
children: List(element.Element(a)),
) -> element.Element(a) {
h.div([a.class("grid grid-cols-" <> cols <> " gap-" <> gap)], children)
}
pub fn definition_list(
cols: Int,
items: List(element.Element(a)),
) -> element.Element(a) {
let cols_class = case cols {
1 -> "grid-cols-1"
2 -> "grid-cols-1 sm:grid-cols-2"
3 -> "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"
4 -> "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4"
_ -> "grid-cols-1 sm:grid-cols-2"
}
h.dl([a.class("grid " <> cols_class <> " gap-x-6 gap-y-2")], items)
}
pub fn back_button(
ctx: Context,
url: String,
label: String,
) -> element.Element(a) {
h.a(
[
href(ctx, url),
a.class(
"inline-flex items-center gap-2 text-neutral-900 hover:text-neutral-600 underline decoration-neutral-300 hover:decoration-neutral-500 text-sm",
),
],
[element.text("" <> label)],
)
}
pub fn not_found_view(
ctx: Context,
resource_name: String,
back_url: String,
back_label: String,
) -> element.Element(a) {
h.div([a.class("max-w-2xl mx-auto")], [
card(PaddingLarge, [
h.div([a.class("text-center space-y-4")], [
h.div(
[
a.class(
"mx-auto w-16 h-16 bg-neutral-100 rounded-full flex items-center justify-center",
),
],
[
h.span([a.class("text-neutral-400 text-2xl font-semibold")], [
element.text("?"),
]),
],
),
h.h2([a.class("text-lg font-semibold text-neutral-900")], [
element.text(resource_name <> " Not Found"),
]),
h.p([a.class("text-neutral-600")], [
element.text(
"The "
<> resource_name
<> " you're looking for doesn't exist or you don't have permission to view it.",
),
]),
h.div([a.class("pt-4")], [back_button(ctx, back_url, back_label)]),
]),
]),
])
}
pub type TableColumn(row, msg) {
TableColumn(
header: String,
cell_class: String,
render: fn(row) -> element.Element(msg),
)
}
pub fn data_table(
columns: List(TableColumn(row, msg)),
rows: List(row),
) -> element.Element(msg) {
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.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)])
}),
)
}),
),
]),
],
)
}
pub fn custom_checkbox(
name: String,
value: String,
label: String,
checked: Bool,
on_change: Option(String),
) -> element.Element(a) {
let checkbox_attrs = case on_change {
option.Some(script) -> [
a.type_("checkbox"),
a.name(name),
a.value(value),
a.checked(checked),
a.class("peer hidden"),
a.attribute("onchange", script),
]
option.None -> [
a.type_("checkbox"),
a.name(name),
a.value(value),
a.checked(checked),
a.class("peer hidden"),
]
}
h.label([a.class("flex items-center gap-3 cursor-pointer group")], [
h.input(checkbox_attrs),
element.element(
"svg",
[
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",
),
],
[
element.element(
"polyline",
[
a.attribute("points", "40 144 96 200 224 72"),
a.attribute("fill", "none"),
a.attribute("stroke", "currentColor"),
a.attribute("stroke-linecap", "round"),
a.attribute("stroke-linejoin", "round"),
a.attribute("stroke-width", "24"),
],
[],
),
],
),
h.span([a.class("text-sm text-neutral-900 group-hover:text-neutral-700")], [
element.text(label),
]),
])
}

View File

@@ -0,0 +1,56 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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 gleam/list
import gleam/option.{type Option}
import gleam/string
import gleam/uri
pub fn build_url(
base: String,
params: List(#(String, Option(String))),
) -> String {
let filtered_params =
params
|> list.filter_map(fn(param) {
let #(key, value_opt) = param
case value_opt {
option.Some(value) -> {
let trimmed = string.trim(value)
case trimmed {
"" -> Error(Nil)
v -> Ok(#(key, v))
}
}
option.None -> Error(Nil)
}
})
case filtered_params {
[] -> base
params -> {
let query_string =
params
|> list.map(fn(pair) {
let #(key, value) = pair
key <> "=" <> uri.percent_encode(value)
})
|> string.join("&")
base <> "?" <> query_string
}
}
}

View File

@@ -0,0 +1,243 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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 gleam/int
import gleam/list
import gleam/string
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
pub fn vip_checkbox(checked: Bool) {
h.div([a.class("space-y-1")], [
h.label([a.class("flex items-center gap-2")], [
h.input([
a.type_("checkbox"),
a.name("vip_only"),
a.value("true"),
a.checked(checked),
]),
h.span([a.class("text-sm text-neutral-700")], [
element.text("Require VIP_VOICE feature"),
]),
]),
h.p([a.class("text-xs text-neutral-500 ml-6")], [
element.text(
"When enabled, guilds MUST have VIP_VOICE feature. This becomes a base requirement that works with AND logic alongside other restrictions.",
),
]),
])
}
pub fn features_field(current_features: List(String)) {
h.div([a.class("space-y-1")], [
h.label([a.class("text-sm text-neutral-700")], [
element.text("Allowed Guild Features (OR logic)"),
]),
h.textarea(
[
a.name("required_guild_features"),
a.placeholder("VANITY_URL, COMMUNITY, PARTNERED"),
a.attribute("rows", "2"),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
],
string.join(current_features, ", "),
),
h.p([a.class("text-xs text-neutral-500")], [
element.text(
"Comma-separated. Guild needs ANY ONE of these features. Leave empty for no feature restrictions.",
),
]),
])
}
pub fn guild_ids_field(current_ids: List(String)) {
h.div([a.class("space-y-1")], [
h.label([a.class("text-sm text-neutral-700")], [
element.text("Allowed Guild IDs (OR logic, bypasses other checks)"),
]),
h.textarea(
[
a.name("allowed_guild_ids"),
a.placeholder("123456789012345678, 987654321098765432"),
a.attribute("rows", "2"),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
],
string.join(current_ids, ", "),
),
h.p([a.class("text-xs text-neutral-500")], [
element.text(
"Comma-separated. If guild ID matches ANY of these, immediate access (bypasses VIP & feature checks).",
),
]),
])
}
pub fn access_logic_summary() {
h.div(
[
a.class(
"mt-4 p-3 bg-blue-50 border border-blue-200 rounded text-sm space-y-2",
),
],
[
h.p([a.class("text-sm font-medium text-blue-900")], [
element.text("Access Logic Summary:"),
]),
h.ul([a.class("text-blue-800 space-y-1 ml-4 list-disc")], [
h.li([], [
element.text(
"Guild IDs provide immediate access (bypass all other checks)",
),
]),
h.li([], [
element.text(
"VIP_VOICE requirement: Base requirement that must be satisfied (AND logic)",
),
]),
h.li([], [
element.text(
"Features: Guild needs ANY ONE of the listed features (OR logic)",
),
]),
h.li([], [
element.text(
"Combined: VIP_VOICE (if set) AND (feature1 OR feature2 OR ...)",
),
]),
]),
],
)
}
pub fn restriction_fields(
vip_only: Bool,
features: List(String),
guild_ids: List(String),
) {
element.fragment([
vip_checkbox(vip_only),
features_field(features),
guild_ids_field(guild_ids),
access_logic_summary(),
])
}
pub fn info_item(label: String, value: String) {
h.div([], [
h.p([a.class("text-xs text-neutral-600")], [element.text(label)]),
h.p([a.class("text-sm text-neutral-900")], [element.text(value)]),
])
}
pub fn vip_badge() {
h.span(
[
a.class("px-2 py-1 bg-purple-100 text-purple-800 text-xs rounded"),
],
[element.text("VIP ONLY")],
)
}
pub fn feature_gated_badge() {
h.span(
[
a.class("px-2 py-1 bg-amber-100 text-amber-800 text-xs rounded"),
],
[element.text("FEATURE GATED")],
)
}
pub fn guild_restricted_badge() {
h.span(
[
a.class("px-2 py-1 bg-green-100 text-green-800 text-xs rounded"),
],
[element.text("GUILD RESTRICTED")],
)
}
pub fn status_badges(vip_only: Bool, has_features: Bool, has_guild_ids: Bool) {
h.div([a.class("flex items-center gap-2 flex-wrap")], [
case vip_only {
True -> vip_badge()
False -> element.none()
},
case has_features {
True -> feature_gated_badge()
False -> element.none()
},
case has_guild_ids {
True -> guild_restricted_badge()
False -> element.none()
},
])
}
pub fn features_list(features: List(String)) {
case list.is_empty(features) {
True -> element.none()
False ->
h.div([a.class("mb-3")], [
h.p([a.class("text-xs text-neutral-700 mb-1")], [
element.text("Allowed Guild Features:"),
]),
h.div(
[a.class("flex flex-wrap gap-1")],
list.map(features, fn(feature) {
h.span(
[
a.class(
"px-2 py-0.5 bg-amber-50 border border-amber-200 text-amber-800 text-xs rounded",
),
],
[element.text(feature)],
)
}),
),
])
}
}
pub fn guild_ids_list(guild_ids: List(String)) {
case list.is_empty(guild_ids) {
True -> element.none()
False ->
h.div([a.class("mb-3")], [
h.p([a.class("text-xs text-neutral-700 mb-1")], [
element.text(
"Allowed Guild IDs ("
<> int.to_string(list.length(guild_ids))
<> "):",
),
]),
h.details([a.class("text-xs text-neutral-600")], [
h.summary([a.class("cursor-pointer hover:text-neutral-900")], [
element.text("Show IDs"),
]),
h.div(
[a.class("mt-1 max-h-20 overflow-y-auto text-xs")],
list.map(guild_ids, fn(id) { h.div([], [element.text(id)]) }),
),
]),
])
}
}

View File

@@ -0,0 +1,151 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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 envoy
import gleam/int
import gleam/list
import gleam/option
import gleam/result
import gleam/string
pub type Config {
Config(
secret_key_base: String,
api_endpoint: String,
media_endpoint: String,
cdn_endpoint: String,
admin_endpoint: String,
web_app_endpoint: String,
metrics_endpoint: option.Option(String),
oauth_client_id: String,
oauth_client_secret: String,
oauth_redirect_uri: String,
port: Int,
base_path: String,
build_timestamp: String,
)
}
fn normalize_base_path(base_path: String) -> String {
let segments =
base_path
|> string.trim
|> string.split("/")
|> list.filter(fn(segment) { segment != "" })
case segments {
[] -> ""
_ -> "/" <> string.join(segments, "/")
}
}
fn normalize_endpoint(endpoint: String) -> String {
let len = string.length(endpoint)
case len > 0 && string.ends_with(endpoint, "/") {
True -> normalize_endpoint(string.slice(endpoint, 0, len - 1))
False -> endpoint
}
}
fn required_env(key: String) -> Result(String, String) {
case envoy.get(key) {
Ok(value) ->
case string.trim(value) {
"" -> Error("Missing required env: " <> key)
trimmed -> Ok(trimmed)
}
Error(_) -> Error("Missing required env: " <> key)
}
}
fn optional_env(key: String) -> option.Option(String) {
case envoy.get(key) {
Ok(value) ->
case string.trim(value) {
"" -> option.None
trimmed -> option.Some(trimmed)
}
Error(_) -> option.None
}
}
fn required_int_env(key: String) -> Result(Int, String) {
use raw <- result.try(required_env(key))
case int.parse(raw) {
Ok(n) -> Ok(n)
Error(_) -> Error("Invalid integer for env " <> key <> ": " <> raw)
}
}
fn build_redirect_uri(
endpoint: String,
base_path: String,
override: option.Option(String),
) -> String {
case override {
option.Some(uri) -> uri
option.None ->
endpoint <> normalize_base_path(base_path) <> "/oauth2_callback"
}
}
pub fn load_config() -> Result(Config, String) {
use api_endpoint_raw <- result.try(required_env("FLUXER_API_PUBLIC_ENDPOINT"))
use media_endpoint_raw <- result.try(required_env("FLUXER_MEDIA_ENDPOINT"))
use cdn_endpoint_raw <- result.try(required_env("FLUXER_CDN_ENDPOINT"))
use admin_endpoint_raw <- result.try(required_env("FLUXER_ADMIN_ENDPOINT"))
use web_app_endpoint_raw <- result.try(required_env("FLUXER_APP_ENDPOINT"))
use secret_key_base <- result.try(required_env("SECRET_KEY_BASE"))
use client_id <- result.try(required_env("ADMIN_OAUTH2_CLIENT_ID"))
use client_secret <- result.try(required_env("ADMIN_OAUTH2_CLIENT_SECRET"))
use base_path_raw <- result.try(required_env("FLUXER_PATH_ADMIN"))
use port <- result.try(required_int_env("FLUXER_ADMIN_PORT"))
let api_endpoint = normalize_endpoint(api_endpoint_raw)
let media_endpoint = normalize_endpoint(media_endpoint_raw)
let cdn_endpoint = normalize_endpoint(cdn_endpoint_raw)
let admin_endpoint = normalize_endpoint(admin_endpoint_raw)
let web_app_endpoint = normalize_endpoint(web_app_endpoint_raw)
let base_path = normalize_base_path(base_path_raw)
let redirect_uri =
build_redirect_uri(
admin_endpoint,
base_path,
optional_env("ADMIN_OAUTH2_REDIRECT_URI"),
)
let metrics_endpoint = case optional_env("FLUXER_METRICS_HOST") {
option.Some(host) -> option.Some("http://" <> host)
option.None -> option.None
}
Ok(Config(
secret_key_base: secret_key_base,
api_endpoint: api_endpoint,
media_endpoint: media_endpoint,
cdn_endpoint: cdn_endpoint,
admin_endpoint: admin_endpoint,
web_app_endpoint: web_app_endpoint,
metrics_endpoint: metrics_endpoint,
oauth_client_id: client_id,
oauth_client_secret: client_secret,
oauth_redirect_uri: redirect_uri,
port: port,
base_path: base_path,
build_timestamp: envoy.get("BUILD_TIMESTAMP") |> result.unwrap(""),
))
}

View File

@@ -0,0 +1,562 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// You should have received a copy of the GNU Affero General Public License
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
pub type Flag {
Flag(name: String, value: Int)
}
pub const flag_staff = Flag("STAFF", 1)
pub const flag_ctp_member = Flag("CTP_MEMBER", 2)
pub const flag_partner = Flag("PARTNER", 4)
pub const flag_bug_hunter = Flag("BUG_HUNTER", 8)
pub const flag_high_global_rate_limit = Flag(
"HIGH_GLOBAL_RATE_LIMIT",
8_589_934_592,
)
pub const flag_premium_purchase_disabled = Flag(
"PREMIUM_PURCHASE_DISABLED",
35_184_372_088_832,
)
pub const flag_premium_enabled_override = Flag(
"PREMIUM_ENABLED_OVERRIDE",
70_368_744_177_664,
)
pub const flag_rate_limit_bypass = Flag(
"RATE_LIMIT_BYPASS",
140_737_488_355_328,
)
pub const flag_report_banned = Flag("REPORT_BANNED", 281_474_976_710_656)
pub const flag_verified_not_underage = Flag(
"VERIFIED_NOT_UNDERAGE",
562_949_953_421_312,
)
pub const flag_pending_manual_verification = Flag(
"PENDING_MANUAL_VERIFICATION",
1_125_899_906_842_624,
)
pub const flag_used_mobile_client = Flag(
"USED_MOBILE_CLIENT",
4_503_599_627_370_496,
)
pub const flag_app_store_reviewer = Flag(
"APP_STORE_REVIEWER",
9_007_199_254_740_992,
)
pub fn get_patchable_flags() -> List(Flag) {
[
flag_staff,
flag_ctp_member,
flag_partner,
flag_bug_hunter,
flag_high_global_rate_limit,
flag_premium_purchase_disabled,
flag_premium_enabled_override,
flag_rate_limit_bypass,
flag_report_banned,
flag_verified_not_underage,
flag_pending_manual_verification,
flag_used_mobile_client,
flag_app_store_reviewer,
]
}
pub const suspicious_flag_require_verified_email = Flag(
"REQUIRE_VERIFIED_EMAIL",
1,
)
pub const suspicious_flag_require_reverified_email = Flag(
"REQUIRE_REVERIFIED_EMAIL",
2,
)
pub const suspicious_flag_require_verified_phone = Flag(
"REQUIRE_VERIFIED_PHONE",
4,
)
pub const suspicious_flag_require_reverified_phone = Flag(
"REQUIRE_REVERIFIED_PHONE",
8,
)
pub const suspicious_flag_require_verified_email_or_verified_phone = Flag(
"REQUIRE_VERIFIED_EMAIL_OR_VERIFIED_PHONE",
16,
)
pub const suspicious_flag_require_reverified_email_or_verified_phone = Flag(
"REQUIRE_REVERIFIED_EMAIL_OR_VERIFIED_PHONE",
32,
)
pub const suspicious_flag_require_verified_email_or_reverified_phone = Flag(
"REQUIRE_VERIFIED_EMAIL_OR_REVERIFIED_PHONE",
64,
)
pub const suspicious_flag_require_reverified_email_or_reverified_phone = Flag(
"REQUIRE_REVERIFIED_EMAIL_OR_REVERIFIED_PHONE",
128,
)
pub fn get_suspicious_activity_flags() -> List(Flag) {
[
suspicious_flag_require_verified_email,
suspicious_flag_require_reverified_email,
suspicious_flag_require_verified_phone,
suspicious_flag_require_reverified_phone,
suspicious_flag_require_verified_email_or_verified_phone,
suspicious_flag_require_reverified_email_or_verified_phone,
suspicious_flag_require_verified_email_or_reverified_phone,
suspicious_flag_require_reverified_email_or_reverified_phone,
]
}
pub const deletion_reason_user_requested = #(1, "User Requested")
pub const deletion_reason_other = #(2, "Other")
pub const deletion_reason_spam = #(3, "Spam")
pub const deletion_reason_hacks_cheats = #(4, "Hacks / Cheats")
pub const deletion_reason_raids = #(5, "Raids")
pub const deletion_reason_selfbot = #(6, "Selfbot")
pub const deletion_reason_nonconsensual_pornography = #(
7,
"Nonconsensual Pornography",
)
pub const deletion_reason_scam = #(8, "Scam")
pub const deletion_reason_lolicon = #(9, "Lolicon")
pub const deletion_reason_doxxing = #(10, "Doxxing")
pub const deletion_reason_harassment = #(11, "Harassment")
pub const deletion_reason_fraudulent_charge = #(12, "Fraudulent Charge")
pub const deletion_reason_coppa = #(13, "COPPA")
pub const deletion_reason_friendly_fraud = #(14, "Friendly Fraud")
pub const deletion_reason_unsolicited_nsfw = #(15, "Unsolicited NSFW")
pub const deletion_reason_gore = #(16, "Gore")
pub const deletion_reason_ban_evasion = #(17, "Ban Evasion")
pub const deletion_reason_token_solicitation = #(18, "Token Solicitation")
pub fn get_deletion_reasons() -> List(#(Int, String)) {
[
deletion_reason_user_requested,
deletion_reason_other,
deletion_reason_spam,
deletion_reason_hacks_cheats,
deletion_reason_raids,
deletion_reason_selfbot,
deletion_reason_nonconsensual_pornography,
deletion_reason_scam,
deletion_reason_lolicon,
deletion_reason_doxxing,
deletion_reason_harassment,
deletion_reason_fraudulent_charge,
deletion_reason_coppa,
deletion_reason_friendly_fraud,
deletion_reason_unsolicited_nsfw,
deletion_reason_gore,
deletion_reason_ban_evasion,
deletion_reason_token_solicitation,
]
}
pub fn get_temp_ban_durations() -> List(#(Int, String)) {
[
#(1, "1 hour"),
#(12, "12 hours"),
#(24, "1 day"),
#(72, "3 days"),
#(120, "5 days"),
#(168, "1 week"),
#(336, "2 weeks"),
#(720, "30 days"),
]
}
pub const acl_wildcard = "*"
pub const acl_authenticate = "admin:authenticate"
pub const acl_process_memory_stats = "process:memory_stats"
pub const acl_user_lookup = "user:lookup"
pub const acl_user_list_sessions = "user:list:sessions"
pub const acl_user_list_guilds = "user:list:guilds"
pub const acl_user_terminate_sessions = "user:terminate:sessions"
pub const acl_user_update_mfa = "user:update:mfa"
pub const acl_user_update_avatar = "user:update:avatar"
pub const acl_user_update_banner = "user:update:banner"
pub const acl_user_update_profile = "user:update:profile"
pub const acl_user_update_bot_status = "user:update:bot_status"
pub const acl_user_update_email = "user:update:email"
pub const acl_user_update_phone = "user:update:phone"
pub const acl_user_update_dob = "user:update:dob"
pub const acl_user_update_username = "user:update:username"
pub const acl_user_update_flags = "user:update:flags"
pub const acl_user_update_suspicious_activity = "user:update:suspicious_activity"
pub const acl_user_temp_ban = "user:temp_ban"
pub const acl_user_disable_suspicious = "user:disable:suspicious"
pub const acl_user_delete = "user:delete"
pub const acl_user_cancel_bulk_message_deletion = "user:cancel:bulk_message_deletion"
pub const acl_beta_codes_generate = "beta_codes:generate"
pub const acl_gift_codes_generate = "gift_codes:generate"
pub const acl_guild_lookup = "guild:lookup"
pub const acl_guild_list_members = "guild:list:members"
pub const acl_guild_reload = "guild:reload"
pub const acl_guild_shutdown = "guild:shutdown"
pub const acl_guild_delete = "guild:delete"
pub const acl_guild_update_name = "guild:update:name"
pub const acl_guild_update_icon = "guild:update:icon"
pub const acl_guild_update_banner = "guild:update:banner"
pub const acl_guild_update_splash = "guild:update:splash"
pub const acl_guild_update_vanity = "guild:update:vanity"
pub const acl_guild_update_features = "guild:update:features"
pub const acl_guild_update_settings = "guild:update:settings"
pub const acl_guild_transfer_ownership = "guild:transfer_ownership"
pub const acl_guild_force_add_member = "guild:force_add_member"
pub const acl_asset_purge = "asset:purge"
pub const acl_message_lookup = "message:lookup"
pub const acl_message_delete = "message:delete"
pub const acl_message_shred = "message:shred"
pub const acl_message_delete_all = "message:delete_all"
pub const acl_ban_ip_check = "ban:ip:check"
pub const acl_ban_ip_add = "ban:ip:add"
pub const acl_ban_ip_remove = "ban:ip:remove"
pub const acl_ban_email_check = "ban:email:check"
pub const acl_ban_email_add = "ban:email:add"
pub const acl_ban_email_remove = "ban:email:remove"
pub const acl_ban_phone_check = "ban:phone:check"
pub const acl_ban_phone_add = "ban:phone:add"
pub const acl_ban_phone_remove = "ban:phone:remove"
pub const acl_bulk_update_user_flags = "bulk:update:user_flags"
pub const acl_bulk_update_guild_features = "bulk:update:guild_features"
pub const acl_bulk_add_guild_members = "bulk:add:guild_members"
pub const acl_archive_view_all = "archive:view_all"
pub const acl_archive_trigger_user = "archive:trigger:user"
pub const acl_archive_trigger_guild = "archive:trigger:guild"
pub const acl_bulk_delete_users = "bulk:delete:users"
pub const acl_audit_log_view = "audit_log:view"
pub const acl_report_view = "report:view"
pub const acl_report_resolve = "report:resolve"
pub const acl_voice_region_list = "voice:region:list"
pub const acl_voice_region_create = "voice:region:create"
pub const acl_voice_region_update = "voice:region:update"
pub const acl_voice_region_delete = "voice:region:delete"
pub const acl_voice_server_list = "voice:server:list"
pub const acl_voice_server_create = "voice:server:create"
pub const acl_voice_server_update = "voice:server:update"
pub const acl_voice_server_delete = "voice:server:delete"
pub const acl_acl_set_user = "acl:set:user"
pub const acl_metrics_view = "metrics:view"
pub const acl_feature_flag_view = "feature_flag:view"
pub const acl_feature_flag_manage = "feature_flag:manage"
pub type FeatureFlag {
FeatureFlag(id: String, name: String, description: String)
}
pub const feature_flag_message_scheduling = FeatureFlag(
"message_scheduling",
"Message Scheduling",
"Allows users to schedule messages to be sent at a later time",
)
pub const feature_flag_expression_packs = FeatureFlag(
"expression_packs",
"Expression Packs",
"Allows users to create and use custom expression packs",
)
pub fn get_feature_flags() -> List(FeatureFlag) {
[feature_flag_message_scheduling, feature_flag_expression_packs]
}
pub type GuildFeature {
GuildFeature(value: String)
}
pub const feature_invite_splash = GuildFeature("INVITE_SPLASH")
pub const feature_vip_voice = GuildFeature("VIP_VOICE")
pub const feature_vanity_url = GuildFeature("VANITY_URL")
pub const feature_more_emoji = GuildFeature("MORE_EMOJI")
pub const feature_more_stickers = GuildFeature("MORE_STICKERS")
pub const feature_unlimited_emoji = GuildFeature("UNLIMITED_EMOJI")
pub const feature_unlimited_stickers = GuildFeature("UNLIMITED_STICKERS")
pub const feature_verified = GuildFeature("VERIFIED")
pub const feature_banner = GuildFeature("BANNER")
pub const feature_animated_banner = GuildFeature("ANIMATED_BANNER")
pub const feature_animated_icon = GuildFeature("ANIMATED_ICON")
pub const feature_invites_disabled = GuildFeature("INVITES_DISABLED")
pub const feature_text_channel_flexible_names = GuildFeature(
"TEXT_CHANNEL_FLEXIBLE_NAMES",
)
pub const feature_unavailable_for_everyone = GuildFeature(
"UNAVAILABLE_FOR_EVERYONE",
)
pub const feature_unavailable_for_everyone_but_staff = GuildFeature(
"UNAVAILABLE_FOR_EVERYONE_BUT_STAFF",
)
pub const feature_detached_banner = GuildFeature("DETACHED_BANNER")
pub const feature_expression_purge_allowed = GuildFeature(
"EXPRESSION_PURGE_ALLOWED",
)
pub const feature_disallow_unclaimed_accounts = GuildFeature(
"DISALLOW_UNCLAIMED_ACCOUNTS",
)
pub const feature_large_guild_override = GuildFeature("LARGE_GUILD_OVERRIDE")
pub fn get_guild_features() -> List(GuildFeature) {
[
feature_animated_icon,
feature_animated_banner,
feature_banner,
feature_invite_splash,
feature_invites_disabled,
feature_more_emoji,
feature_more_stickers,
feature_unlimited_emoji,
feature_unlimited_stickers,
feature_text_channel_flexible_names,
feature_unavailable_for_everyone,
feature_unavailable_for_everyone_but_staff,
feature_vanity_url,
feature_verified,
feature_vip_voice,
feature_detached_banner,
feature_expression_purge_allowed,
feature_disallow_unclaimed_accounts,
feature_large_guild_override,
]
}
pub const disabled_op_push_notifications = Flag("PUSH_NOTIFICATIONS", 1)
pub const disabled_op_everyone_mentions = Flag("EVERYONE_MENTIONS", 2)
pub const disabled_op_typing_events = Flag("TYPING_EVENTS", 4)
pub const disabled_op_instant_invites = Flag("INSTANT_INVITES", 8)
pub const disabled_op_send_message = Flag("SEND_MESSAGE", 16)
pub const disabled_op_reactions = Flag("REACTIONS", 32)
pub fn get_disabled_operations() -> List(Flag) {
[
disabled_op_push_notifications,
disabled_op_everyone_mentions,
disabled_op_typing_events,
disabled_op_instant_invites,
disabled_op_send_message,
disabled_op_reactions,
]
}
pub fn get_all_acls() -> List(String) {
[
acl_wildcard,
acl_authenticate,
acl_process_memory_stats,
acl_user_lookup,
acl_user_list_sessions,
acl_user_list_guilds,
acl_user_terminate_sessions,
acl_user_update_mfa,
acl_user_update_avatar,
acl_user_update_banner,
acl_user_update_profile,
acl_user_update_bot_status,
acl_user_update_email,
acl_user_update_phone,
acl_user_update_dob,
acl_user_update_username,
acl_user_update_flags,
acl_user_update_suspicious_activity,
acl_user_temp_ban,
acl_user_disable_suspicious,
acl_user_delete,
acl_user_cancel_bulk_message_deletion,
acl_beta_codes_generate,
acl_gift_codes_generate,
acl_guild_lookup,
acl_guild_list_members,
acl_guild_reload,
acl_guild_shutdown,
acl_guild_delete,
acl_guild_update_name,
acl_guild_update_icon,
acl_guild_update_banner,
acl_guild_update_splash,
acl_guild_update_vanity,
acl_guild_update_features,
acl_guild_update_settings,
acl_guild_transfer_ownership,
acl_guild_force_add_member,
acl_asset_purge,
acl_message_lookup,
acl_message_delete,
acl_message_shred,
acl_message_delete_all,
acl_ban_ip_check,
acl_ban_ip_add,
acl_ban_ip_remove,
acl_ban_email_check,
acl_ban_email_add,
acl_ban_email_remove,
acl_ban_phone_check,
acl_ban_phone_add,
acl_ban_phone_remove,
acl_bulk_update_user_flags,
acl_bulk_update_guild_features,
acl_bulk_add_guild_members,
acl_archive_view_all,
acl_archive_trigger_user,
acl_archive_trigger_guild,
acl_bulk_delete_users,
acl_audit_log_view,
acl_report_view,
acl_report_resolve,
acl_voice_region_list,
acl_voice_region_create,
acl_voice_region_update,
acl_voice_region_delete,
acl_voice_server_list,
acl_voice_server_create,
acl_voice_server_update,
acl_voice_server_delete,
acl_acl_set_user,
acl_metrics_view,
acl_feature_flag_view,
acl_feature_flag_manage,
]
}

View File

@@ -0,0 +1,31 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// You should have received a copy of the GNU Affero General Public License
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
@external(erlang, "io", "format")
fn erlang_io_format(fmt: String, args: List(String)) -> Nil
pub fn debug(msg: String) {
erlang_io_format("[debug] " <> msg <> "\n", [])
}
pub fn info(msg: String) {
erlang_io_format("[info] " <> msg <> "\n", [])
}
pub fn error(msg: String) {
erlang_io_format("[error] " <> msg <> "\n", [])
}

View File

@@ -0,0 +1,51 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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 gleam/http/response.{type Response}
import gleam/list
import gleam/result
import gleam/string
import wisp
pub fn add_cache_headers(res: Response(wisp.Body)) -> Response(wisp.Body) {
case list.key_find(res.headers, "cache-control") {
Ok(_) -> res
Error(_) -> {
let content_type =
list.key_find(res.headers, "content-type")
|> result.unwrap("")
let cache_header = case should_cache(content_type) {
True -> "public, max-age=31536000, immutable"
False -> "no-cache"
}
response.set_header(res, "cache-control", cache_header)
}
}
}
fn should_cache(content_type: String) -> Bool {
let cacheable_types = [
"text/css", "application/javascript", "font/", "image/", "video/", "audio/",
"application/font-woff2",
]
list.any(cacheable_types, fn(type_prefix) {
string.starts_with(content_type, type_prefix)
})
}

View File

@@ -0,0 +1,20 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// You should have received a copy of the GNU Affero General Public License
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
pub fn get_app_title() -> String {
"Fluxer Admin"
}

View File

@@ -0,0 +1,42 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/web.{type Context}
import gleam/bit_array
import gleam/crypto
pub fn authorize_url(ctx: Context, state: String) -> String {
ctx.web_app_endpoint
<> "/oauth2/authorize?response_type=code&client_id="
<> ctx.oauth_client_id
<> "&redirect_uri="
<> ctx.oauth_redirect_uri
<> "&scope=identify%20email"
<> "&state="
<> state
}
pub fn base64_encode_string(value: String) -> String {
value
|> bit_array.from_string
|> bit_array.base64_encode(True)
}
pub fn generate_state() -> String {
crypto.strong_random_bytes(32)
|> bit_array.base64_url_encode(False)
}

View File

@@ -0,0 +1,233 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/archives
import fluxer_admin/api/common
import fluxer_admin/components/date_time
import fluxer_admin/components/errors
import fluxer_admin/components/flash
import fluxer_admin/components/layout
import fluxer_admin/components/ui
import fluxer_admin/web.{type Context, type Session, href}
import gleam/int
import gleam/list
import gleam/option
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Response}
pub fn view(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
subject_type: String,
subject_id: option.Option(String),
) -> Response {
let result =
archives.list_archives(ctx, session, subject_type, subject_id, False)
let content = case result {
Ok(response) ->
render_archives(ctx, response.archives, subject_type, subject_id)
Error(err) -> errors.api_error_view(ctx, err, option.None, option.None)
}
let html =
layout.page(
"Archives",
"archives",
ctx,
session,
current_admin,
flash_data,
content,
)
wisp.html_response(element.to_document_string(html), 200)
}
fn render_archives(
ctx: Context,
archives: List(archives.Archive),
subject_type: String,
subject_id: option.Option(String),
) {
let filter_hint = case subject_id {
option.Some(id) -> " for " <> subject_type <> " " <> id
option.None -> ""
}
h.div([a.class("max-w-7xl mx-auto")], [
ui.flex_row_between([
ui.heading_page("Archives" <> filter_hint),
h.div([], []),
]),
case list.is_empty(archives) {
True ->
h.div(
[
a.class(
"bg-white border border-dashed border-neutral-300 rounded-lg p-8 text-center",
),
],
[
h.p([a.class("text-neutral-600")], [
element.text("No archives found" <> filter_hint <> "."),
]),
],
)
False -> render_table(ctx, archives)
},
])
}
fn render_table(ctx: Context, archives: List(archives.Archive)) {
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([], [
header_cell("Subject"),
header_cell("Requested By"),
header_cell("Requested At"),
header_cell("Status"),
header_cell("Actions"),
]),
]),
h.tbody(
[a.class("divide-y divide-neutral-200")],
list.map(archives, fn(archive) {
h.tr([], [
h.td(
[
a.class(
"px-6 py-4 whitespace-nowrap text-sm text-neutral-900",
),
],
[
h.div([], [
h.div([a.class("font-semibold")], [
element.text(
archive.subject_type <> " " <> archive.subject_id,
),
]),
h.div([a.class("text-neutral-500 text-xs")], [
element.text("Archive ID: " <> archive.archive_id),
]),
]),
],
),
h.td(
[
a.class(
"px-6 py-4 whitespace-nowrap text-sm text-neutral-900",
),
],
[
element.text(archive.requested_by),
],
),
h.td(
[
a.class(
"px-6 py-4 whitespace-nowrap text-sm text-neutral-900",
),
],
[
element.text(date_time.format_timestamp(archive.requested_at)),
],
),
h.td([a.class("px-6 py-4 text-sm")], [
h.div([a.class("flex items-center gap-2")], [
h.span(
[
a.class(
"inline-flex items-center px-2 py-1 rounded-full bg-neutral-100 text-neutral-800 text-xs",
),
],
[element.text(status_label(archive))],
),
h.span([a.class("text-neutral-600 text-xs")], [
element.text(int.to_string(archive.progress_percent) <> "%"),
]),
]),
case archive.progress_step {
option.Some(step) ->
h.div([a.class("text-xs text-neutral-500 mt-1")], [
element.text(step),
])
option.None -> element.none()
},
]),
h.td([a.class("px-6 py-4 whitespace-nowrap text-sm")], [
case archive.completed_at {
option.Some(_) ->
h.a(
[
href(
ctx,
"/archives/download?subject_type="
<> archive.subject_type
<> "&subject_id="
<> archive.subject_id
<> "&archive_id="
<> archive.archive_id,
),
a.class(
"inline-flex items-center px-3 py-1.5 text-sm font-medium text-white bg-neutral-900 rounded-md hover:bg-neutral-800 transition-colors",
),
],
[element.text("Download")],
)
option.None ->
h.span([a.class("text-neutral-500")], [
element.text("Not ready"),
])
},
]),
])
}),
),
]),
],
)
}
fn header_cell(label: String) {
h.th(
[
a.class(
"px-6 py-3 text-left text-xs font-medium text-neutral-700 uppercase tracking-wider",
),
],
[element.text(label)],
)
}
fn status_label(archive: archives.Archive) -> String {
case archive.failed_at {
option.Some(_) -> "Failed"
option.None -> {
case archive.completed_at {
option.Some(_) -> "Completed"
option.None -> "In Progress"
}
}
}
}

View File

@@ -0,0 +1,244 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/assets
import fluxer_admin/api/common
import fluxer_admin/components/flash
import fluxer_admin/components/layout
import fluxer_admin/components/ui
import fluxer_admin/constants
import fluxer_admin/web
import gleam/int
import gleam/list
import gleam/option
import gleam/string
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Request, type Response}
pub fn view(
ctx: web.Context,
session: web.Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
result: option.Option(assets.AssetPurgeResponse),
) -> Response {
let has_permission = case current_admin {
option.Some(admin) ->
acl.has_permission(admin.acls, constants.acl_asset_purge)
option.None -> False
}
let content =
h.div([a.class("space-y-6")], [
h.div([a.class("mb-6")], [ui.heading_page("Asset Purge")]),
h.div([a.class("text-sm text-neutral-600")], [
element.text(
"Purge emojis or stickers from the storage and CDN. Provide one or more IDs (comma-separated).",
),
]),
case result {
option.Some(response) -> render_result(response)
option.None -> element.none()
},
case has_permission {
True -> render_form()
False -> render_permission_notice()
},
])
let html =
layout.page(
"Asset Purge",
"asset-purge",
ctx,
session,
current_admin,
flash_data,
content,
)
wisp.html_response(element.to_document_string(html), 200)
}
fn render_form() {
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Purge Assets"),
h.p([a.class("text-sm text-neutral-500 mb-4")], [
element.text(
"Enter the emoji or sticker IDs that should be removed from S3 and CDN caches.",
),
]),
h.form(
[
a.method("POST"),
a.action("?action=purge-assets"),
a.class("space-y-4"),
],
[
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("IDs (comma or newline separated)"),
]),
h.textarea(
[
a.name("asset_ids"),
a.required(True),
a.placeholder("123456789012345678\n876543210987654321"),
a.attribute("rows", "4"),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
],
"",
),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Audit Log Reason (optional)"),
]),
h.input([
a.type_("text"),
a.name("audit_log_reason"),
a.placeholder("DMCA takedown request"),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
]),
]),
ui.button("Purge Assets", "submit", ui.Danger, ui.Medium, ui.Full, []),
],
),
])
}
fn render_permission_notice() {
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Permission required"),
h.p([a.class("text-sm text-neutral-600")], [
element.text("You need the asset:purge ACL to use this tool."),
]),
])
}
fn render_result(result: assets.AssetPurgeResponse) {
h.div([a.class("space-y-4")], [
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Purge Result"),
h.div([a.class("text-sm text-neutral-600 mb-4")], [
element.text(
"Processed "
<> int.to_string(list.length(result.processed))
<> " ID(s); "
<> int.to_string(list.length(result.errors))
<> " error(s).",
),
]),
render_processed_table(result.processed),
case list.is_empty(result.errors) {
True -> element.none()
False -> render_errors(result.errors)
},
]),
])
}
fn render_processed_table(items: List(assets.AssetPurgeResult)) {
h.div([a.class("overflow-x-auto border border-neutral-200 rounded-lg")], [
h.table([a.class("min-w-full text-left text-sm text-neutral-700")], [
h.thead([a.class("bg-neutral-50 text-xs uppercase text-neutral-500")], [
h.tr([], [
h.th([a.class("px-4 py-2 font-medium")], [element.text("ID")]),
h.th([a.class("px-4 py-2 font-medium")], [element.text("Type")]),
h.th([a.class("px-4 py-2 font-medium")], [element.text("In DB")]),
h.th([a.class("px-4 py-2 font-medium")], [element.text("Guild ID")]),
]),
]),
h.tbody([], {
list.map(items, fn(item) {
h.tr([a.class("border-t border-neutral-100")], [
h.td([a.class("px-4 py-3 break-words")], [element.text(item.id)]),
h.td([a.class("px-4 py-3")], [element.text(item.asset_type)]),
h.td([a.class("px-4 py-3")], [
element.text(case item.found_in_db {
True -> "Yes"
False -> "No"
}),
]),
h.td([a.class("px-4 py-3")], [
element.text(option.unwrap(item.guild_id, "")),
]),
])
})
}),
]),
])
}
fn render_errors(errors: List(assets.AssetPurgeError)) {
h.div([a.class("mt-4 space-y-2")], {
list.map(errors, fn(err) {
h.div([a.class("text-sm text-red-600")], [
element.text(err.id <> ": " <> err.error),
])
})
})
}
pub fn handle_action(
req: Request,
ctx: web.Context,
session: web.Session,
current_admin: option.Option(common.UserLookupResult),
) -> Response {
use form_data <- wisp.require_form(req)
let ids_input =
list.key_find(form_data.values, "asset_ids")
|> option.from_result
|> option.unwrap("")
let normalized =
string.replace(ids_input, "\n", ",")
|> string.replace("\r", ",")
let ids =
string.split(normalized, ",")
|> list.map(string.trim)
|> list.filter(fn(id) { !string.is_empty(id) })
let audit_log_reason =
list.key_find(form_data.values, "audit_log_reason")
|> option.from_result
case list.is_empty(ids) {
True ->
flash.redirect_with_error(ctx, "/asset-purge", "Provide at least one ID.")
False ->
case assets.purge_assets(ctx, session, ids, audit_log_reason) {
Ok(response) ->
view(ctx, session, current_admin, option.None, option.Some(response))
Error(_) ->
flash.redirect_with_error(
ctx,
"/asset-purge",
"Failed to purge assets.",
)
}
}
}

View File

@@ -0,0 +1,683 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/audit
import fluxer_admin/api/common
import fluxer_admin/components/date_time
import fluxer_admin/components/errors
import fluxer_admin/components/flash
import fluxer_admin/components/layout
import fluxer_admin/components/ui
import fluxer_admin/web.{type Context, type Session, href}
import gleam/int
import gleam/list
import gleam/option
import gleam/string
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Response}
pub fn view(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
query: option.Option(String),
admin_user_id_filter: option.Option(String),
target_type: option.Option(String),
target_id: option.Option(String),
action: option.Option(String),
current_page: Int,
) -> Response {
let limit = 50
let offset = { current_page - 1 } * limit
let result =
audit.search_audit_logs(
ctx,
session,
query,
admin_user_id_filter,
target_type,
target_id,
action,
limit,
offset,
)
let content = case result {
Ok(response) -> {
let total_pages = { response.total + limit - 1 } / limit
h.div([a.class("max-w-7xl mx-auto")], [
ui.flex_row_between([
ui.heading_page("Audit Logs"),
h.div([a.class("flex items-center gap-4")], [
h.span([a.class("text-sm text-neutral-600")], [
element.text(
"Showing "
<> int.to_string(list.length(response.logs))
<> " of "
<> int.to_string(response.total)
<> " entries",
),
]),
]),
]),
render_filters(
ctx,
query,
admin_user_id_filter,
target_type,
target_id,
action,
),
case list.is_empty(response.logs) {
True -> empty_state()
False -> render_logs_table(ctx, response.logs)
},
case response.total > limit {
True ->
render_pagination(
ctx,
current_page,
total_pages,
query,
admin_user_id_filter,
target_type,
target_id,
action,
)
False -> element.none()
},
])
}
Error(err) -> errors.api_error_view(ctx, err, option.None, option.None)
}
let html =
layout.page(
"Audit Logs",
"audit-logs",
ctx,
session,
current_admin,
flash_data,
content,
)
wisp.html_response(element.to_document_string(html), 200)
}
fn render_filters(
ctx: Context,
query: option.Option(String),
admin_user_id_filter: option.Option(String),
target_type: option.Option(String),
target_id: option.Option(String),
action: option.Option(String),
) {
h.div([a.class("bg-white border border-neutral-200 rounded-lg p-4 mb-6")], [
h.form([a.method("get"), a.class("space-y-4")], [
h.div([a.class("w-full")], [
h.label([a.class("block text-sm text-neutral-700 mb-2")], [
element.text("Search"),
]),
h.input([
a.type_("text"),
a.name("q"),
a.value(option.unwrap(query, "")),
a.placeholder("Search audit logs by action, reason, or metadata..."),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:border-transparent",
),
]),
]),
h.div([a.class("grid grid-cols-1 md:grid-cols-4 gap-4")], [
h.div([a.class("flex-1")], [
h.label([a.class("block text-sm text-neutral-700 mb-2")], [
element.text("Action"),
]),
h.select(
[
a.name("action"),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:border-transparent",
),
],
[
h.option([a.value(""), a.selected(option.is_none(action))], "All"),
h.option(
[
a.value("temp_ban"),
a.selected(action == option.Some("temp_ban")),
],
"Temp Ban",
),
h.option(
[a.value("unban"), a.selected(action == option.Some("unban"))],
"Unban",
),
h.option(
[
a.value("schedule_deletion"),
a.selected(action == option.Some("schedule_deletion")),
],
"Schedule Deletion",
),
h.option(
[
a.value("cancel_deletion"),
a.selected(action == option.Some("cancel_deletion")),
],
"Cancel Deletion",
),
h.option(
[
a.value("update_flags"),
a.selected(action == option.Some("update_flags")),
],
"Update Flags",
),
h.option(
[
a.value("update_features"),
a.selected(action == option.Some("update_features")),
],
"Update Features",
),
h.option(
[
a.value("delete_message"),
a.selected(action == option.Some("delete_message")),
],
"Delete Message",
),
h.option(
[a.value("ban_ip"), a.selected(action == option.Some("ban_ip"))],
"Ban IP",
),
h.option(
[
a.value("ban_email"),
a.selected(action == option.Some("ban_email")),
],
"Ban Email",
),
h.option(
[
a.value("ban_phone"),
a.selected(action == option.Some("ban_phone")),
],
"Ban Phone",
),
],
),
]),
h.div([a.class("flex-1")], [
h.label([a.class("block text-sm text-neutral-700 mb-2")], [
element.text("Target Type"),
]),
h.select(
[
a.name("target_type"),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:border-transparent",
),
],
[
h.option([a.value("")], "All types"),
h.option(
[
a.value("user"),
a.selected(target_type == option.Some("user")),
],
"User",
),
h.option(
[
a.value("guild"),
a.selected(target_type == option.Some("guild")),
],
"Guild",
),
h.option(
[
a.value("message"),
a.selected(target_type == option.Some("message")),
],
"Message",
),
h.option(
[a.value("ip"), a.selected(target_type == option.Some("ip"))],
"IP",
),
h.option(
[
a.value("email"),
a.selected(target_type == option.Some("email")),
],
"Email",
),
h.option(
[
a.value("phone"),
a.selected(target_type == option.Some("phone")),
],
"Phone",
),
],
),
]),
h.div([a.class("flex-1")], [
h.label([a.class("block text-sm text-neutral-700 mb-2")], [
element.text("Target ID"),
]),
h.input([
a.type_("text"),
a.name("target_id"),
a.value(option.unwrap(target_id, "")),
a.placeholder("Filter by target ID..."),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:border-transparent",
),
]),
]),
h.div([a.class("flex-1")], [
h.label([a.class("block text-sm text-neutral-700 mb-2")], [
element.text("Admin User ID (optional)"),
]),
h.input([
a.type_("text"),
a.name("admin_user_id"),
a.value(option.unwrap(admin_user_id_filter, "")),
a.placeholder("Specific admin user ID..."),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:border-transparent",
),
]),
]),
]),
h.div([a.class("flex gap-2")], [
h.button(
[
a.type_("submit"),
a.class(
"px-4 py-2 bg-neutral-900 text-white rounded-lg text-sm font-medium hover:bg-neutral-800 transition-colors",
),
],
[element.text("Search & Filter")],
),
h.a(
[
href(ctx, "/audit-logs"),
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",
),
],
[element.text("Clear")],
),
]),
]),
])
}
fn render_logs_table(ctx: Context, logs: List(audit.AuditLog)) {
ui.table_container([
h.table([a.class("min-w-full divide-y divide-neutral-200")], [
h.thead([a.class("bg-neutral-50")], [
h.tr([], [
ui.table_header_cell("Timestamp"),
ui.table_header_cell("Action"),
ui.table_header_cell("Admin"),
ui.table_header_cell("Target"),
ui.table_header_cell("Reason"),
ui.table_header_cell("Details"),
]),
]),
h.tbody(
[a.class("bg-white divide-y divide-neutral-200")],
list.map(logs, fn(log) { render_log_row(ctx, log) }),
),
]),
])
}
fn render_log_row(ctx: Context, log: audit.AuditLog) {
let expanded_id = "expanded-" <> log.log_id
case list.is_empty(log.metadata) {
True ->
h.tr([a.class("hover:bg-neutral-50 transition-colors")], [
h.td([a.class(ui.table_cell_class <> " whitespace-nowrap")], [
element.text(date_time.format_timestamp(log.created_at)),
]),
h.td([a.class("px-6 py-4 whitespace-nowrap")], [
action_pill(log.action),
]),
render_admin_cell(ctx, log.admin_user_id),
render_target_cell(ctx, log.target_type, log.target_id),
h.td([a.class(ui.table_cell_muted_class)], [
case log.audit_log_reason {
option.Some(reason) -> element.text(reason)
option.None ->
h.span([a.class("text-neutral-400 italic")], [element.text("")])
},
]),
h.td([a.class(ui.table_cell_muted_class)], [
h.span([a.class("text-neutral-400 italic")], [element.text("")]),
]),
])
False ->
element.fragment([
h.tr([a.class("hover:bg-neutral-50 transition-colors")], [
h.td([a.class(ui.table_cell_class <> " whitespace-nowrap")], [
element.text(date_time.format_timestamp(log.created_at)),
]),
h.td([a.class("px-6 py-4 whitespace-nowrap")], [
action_pill(log.action),
]),
render_admin_cell(ctx, log.admin_user_id),
render_target_cell(ctx, log.target_type, log.target_id),
h.td([a.class(ui.table_cell_muted_class)], [
case log.audit_log_reason {
option.Some(reason) -> element.text(reason)
option.None ->
h.span([a.class("text-neutral-400 italic")], [element.text("")])
},
]),
h.td([a.class(ui.table_cell_muted_class)], [
h.button(
[
a.class(
"cursor-pointer text-neutral-900 hover:text-neutral-600 underline decoration-neutral-300 hover:decoration-neutral-500",
),
a.attribute(
"onclick",
"document.getElementById('"
<> expanded_id
<> "').classList.toggle('hidden')",
),
],
[element.text("Toggle details")],
),
]),
]),
h.tr([a.id(expanded_id), a.class("hidden bg-neutral-50")], [
h.td([a.attribute("colspan", "6"), a.class("px-6 py-4")], [
render_metadata_expanded(log.metadata),
]),
]),
])
}
}
fn render_admin_cell(ctx: Context, admin_user_id: String) {
h.td([a.class(ui.table_cell_class <> " whitespace-nowrap")], [
case string.is_empty(admin_user_id) {
True -> h.span([a.class("text-neutral-400 italic")], [element.text("")])
False ->
h.a(
[
href(ctx, "/users/" <> admin_user_id),
a.class(
"text-neutral-900 hover:text-neutral-600 underline decoration-neutral-300 hover:decoration-neutral-500",
),
],
[element.text("User " <> admin_user_id)],
)
},
])
}
fn render_target_cell(ctx: Context, target_type: String, target_id: String) {
h.td([a.class(ui.table_cell_class <> " whitespace-nowrap")], [
case target_type, target_id {
"user", id -> {
h.a(
[
href(ctx, "/users/" <> id),
a.class(
"text-neutral-900 hover:text-neutral-600 underline decoration-neutral-300 hover:decoration-neutral-500",
),
],
[element.text("User " <> id)],
)
}
"guild", id -> {
h.a(
[
href(ctx, "/guilds/" <> id),
a.class(
"text-neutral-900 hover:text-neutral-600 underline decoration-neutral-300 hover:decoration-neutral-500",
),
],
[element.text("Guild " <> id)],
)
}
type_, id ->
h.span([a.class("text-neutral-900")], [
element.text(string.capitalise(type_) <> " " <> id),
])
},
])
}
fn render_metadata_expanded(metadata: List(#(String, String))) {
h.div(
[a.class("grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3")],
list.map(metadata, fn(entry) {
let #(key, value) = entry
h.div([a.class("bg-white border border-neutral-200 rounded-lg p-3")], [
h.div([a.class("text-xs text-neutral-500 uppercase mb-1")], [
element.text(key),
]),
h.div([a.class("text-sm text-neutral-900 break-all")], [
element.text(value),
]),
])
}),
)
}
fn format_action(action: String) -> String {
action
|> string.replace("_", " ")
|> string.capitalise
}
fn action_pill(action: String) {
ui.pill(format_action(action), action_tone(action))
}
fn action_tone(action: String) -> ui.PillTone {
case action {
"temp_ban"
| "disable_suspicious_activity"
| "schedule_deletion"
| "ban_ip"
| "ban_email"
| "ban_phone" -> ui.PillDanger
"unban" | "cancel_deletion" | "unban_ip" | "unban_email" | "unban_phone" ->
ui.PillSuccess
"update_flags" | "update_features" | "set_acls" | "update_settings" ->
ui.PillInfo
"delete_message" -> ui.PillOrange
_ -> ui.PillNeutral
}
}
fn empty_state() {
ui.card_empty([
ui.text_muted("No audit logs found"),
ui.text_small_muted("Try adjusting your filters or check back later"),
])
}
fn render_pagination(
ctx: Context,
current_page: Int,
total_pages: Int,
query: option.Option(String),
admin_user_id_filter: option.Option(String),
target_type: option.Option(String),
target_id: option.Option(String),
action: option.Option(String),
) {
let build_url = fn(page: Int) {
let base = "/audit-logs?page=" <> int.to_string(page)
let with_query = case query {
option.Some(q) if q != "" -> base <> "&q=" <> q
_ -> base
}
let with_admin_user = case admin_user_id_filter {
option.Some(id) if id != "" -> with_query <> "&admin_user_id=" <> id
_ -> with_query
}
let with_target_type = case target_type {
option.Some(tt) if tt != "" -> with_admin_user <> "&target_type=" <> tt
_ -> with_admin_user
}
let with_target_id = case target_id {
option.Some(tid) if tid != "" -> with_target_type <> "&target_id=" <> tid
_ -> with_target_type
}
let with_action = case action {
option.Some(act) if act != "" -> with_target_id <> "&action=" <> act
_ -> with_target_id
}
with_action
}
h.div(
[
a.class(
"mt-6 flex items-center justify-between border-t border-neutral-200 bg-white px-4 py-3 sm:px-6 rounded-b-lg",
),
],
[
h.div([a.class("flex flex-1 justify-between sm:hidden")], [
case current_page > 1 {
True ->
h.a(
[
href(ctx, build_url(current_page - 1)),
a.class(
"relative inline-flex items-center rounded-md border border-neutral-300 bg-white px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50",
),
],
[element.text("Previous")],
)
False ->
h.span(
[
a.class(
"relative inline-flex items-center rounded-md border border-neutral-300 bg-neutral-100 px-4 py-2 text-sm font-medium text-neutral-400 cursor-not-allowed",
),
],
[element.text("Previous")],
)
},
case current_page < total_pages {
True ->
h.a(
[
href(ctx, build_url(current_page + 1)),
a.class(
"relative ml-3 inline-flex items-center rounded-md border border-neutral-300 bg-white px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50",
),
],
[element.text("Next")],
)
False ->
h.span(
[
a.class(
"relative ml-3 inline-flex items-center rounded-md border border-neutral-300 bg-neutral-100 px-4 py-2 text-sm font-medium text-neutral-400 cursor-not-allowed",
),
],
[element.text("Next")],
)
},
]),
h.div(
[a.class("hidden sm:flex sm:flex-1 sm:items-center sm:justify-between")],
[
h.div([], [
h.p([a.class("text-sm text-neutral-700")], [
element.text(
"Page "
<> int.to_string(current_page)
<> " of "
<> int.to_string(total_pages),
),
]),
]),
h.div([], [
h.nav(
[a.class("isolate inline-flex -space-x-px rounded-md shadow-sm")],
[
case current_page > 1 {
True ->
h.a(
[
href(ctx, build_url(current_page - 1)),
a.class(
"relative inline-flex items-center rounded-l-md px-4 py-2 text-neutral-900 ring-1 ring-inset ring-neutral-300 hover:bg-neutral-50 focus:z-20 focus:outline-offset-0",
),
],
[element.text("Previous")],
)
False ->
h.span(
[
a.class(
"relative inline-flex items-center rounded-l-md px-4 py-2 text-neutral-400 ring-1 ring-inset ring-neutral-300 bg-neutral-100 cursor-not-allowed",
),
],
[element.text("Previous")],
)
},
case current_page < total_pages {
True ->
h.a(
[
href(ctx, build_url(current_page + 1)),
a.class(
"relative inline-flex items-center rounded-r-md px-4 py-2 text-neutral-900 ring-1 ring-inset ring-neutral-300 hover:bg-neutral-50 focus:z-20 focus:outline-offset-0",
),
],
[element.text("Next")],
)
False ->
h.span(
[
a.class(
"relative inline-flex items-center rounded-r-md px-4 py-2 text-neutral-400 ring-1 ring-inset ring-neutral-300 bg-neutral-100 cursor-not-allowed",
),
],
[element.text("Next")],
)
},
],
),
]),
],
),
],
)
}

View File

@@ -0,0 +1,279 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/bans
import fluxer_admin/api/common
import fluxer_admin/components/flash
import fluxer_admin/components/layout
import fluxer_admin/components/ui
import fluxer_admin/web.{type Context, type Session}
import gleam/list
import gleam/option
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Request, type Response}
pub type BanType {
IpBan
EmailBan
PhoneBan
}
type BanConfig {
BanConfig(
title: String,
route: String,
input_label: String,
input_name: String,
input_type: ui.InputType,
placeholder: String,
entity_name: String,
active_page: String,
)
}
fn get_config(ban_type: BanType) -> BanConfig {
case ban_type {
IpBan ->
BanConfig(
title: "IP Bans",
route: "/ip-bans",
input_label: "IP Address or CIDR",
input_name: "ip",
input_type: ui.Text,
placeholder: "192.168.1.1 or 192.168.0.0/16",
entity_name: "IP/CIDR",
active_page: "ip-bans",
)
EmailBan ->
BanConfig(
title: "Email Bans",
route: "/email-bans",
input_label: "Email Address",
input_name: "email",
input_type: ui.Email,
placeholder: "user@example.com",
entity_name: "Email",
active_page: "email-bans",
)
PhoneBan ->
BanConfig(
title: "Phone Bans",
route: "/phone-bans",
input_label: "Phone Number",
input_name: "phone",
input_type: ui.Tel,
placeholder: "+1234567890",
entity_name: "Phone",
active_page: "phone-bans",
)
}
}
pub fn view(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
ban_type: BanType,
) -> Response {
let config = get_config(ban_type)
let content =
ui.stack("6", [
ui.heading_page(config.title),
ui.grid("1 md:grid-cols-2", "6", [
ui.card(ui.PaddingMedium, [
ui.stack("4", [
ui.heading_card("Ban " <> config.input_label),
h.form([a.method("POST"), a.action("?action=ban")], [
ui.stack("4", [
ui.input(
config.input_label,
config.input_name,
config.input_type,
option.None,
True,
option.Some(config.placeholder),
),
ui.button(
"Ban " <> config.entity_name,
"submit",
ui.Primary,
ui.Medium,
ui.Full,
[],
),
]),
]),
]),
]),
ui.card(ui.PaddingMedium, [
ui.stack("4", [
ui.heading_card("Check " <> config.input_label <> " Ban Status"),
h.form([a.method("POST"), a.action("?action=check")], [
ui.stack("4", [
ui.input(
config.input_label,
config.input_name,
config.input_type,
option.None,
True,
option.Some(config.placeholder),
),
ui.button(
"Check Status",
"submit",
ui.Primary,
ui.Medium,
ui.Full,
[],
),
]),
]),
]),
]),
]),
ui.card(ui.PaddingMedium, [
ui.stack("4", [
ui.heading_card("Remove " <> config.input_label <> " Ban"),
h.form([a.method("POST"), a.action("?action=unban")], [
ui.stack("4", [
ui.input(
config.input_label,
config.input_name,
config.input_type,
option.None,
True,
option.Some(config.placeholder),
),
ui.button(
"Unban " <> config.entity_name,
"submit",
ui.Danger,
ui.Medium,
ui.Full,
[],
),
]),
]),
]),
]),
])
let html =
layout.page(
config.title,
config.active_page,
ctx,
session,
current_admin,
flash_data,
content,
)
wisp.html_response(element.to_document_string(html), 200)
}
pub fn handle_action(
req: Request,
ctx: Context,
session: Session,
ban_type: BanType,
action: option.Option(String),
) -> Response {
use form_data <- wisp.require_form(req)
let config = get_config(ban_type)
let value_result = list.key_find(form_data.values, config.input_name)
case action, value_result {
option.Some("ban"), Ok(value) -> {
let result = case ban_type {
IpBan -> bans.ban_ip(ctx, session, value)
EmailBan -> bans.ban_email(ctx, session, value, option.None)
PhoneBan -> bans.ban_phone(ctx, session, value)
}
case result {
Ok(_) ->
flash.redirect_with_success(
ctx,
config.route,
config.entity_name <> " " <> value <> " banned successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
config.route,
"Failed to ban " <> config.entity_name <> " " <> value,
)
}
}
option.Some("unban"), Ok(value) -> {
let result = case ban_type {
IpBan -> bans.unban_ip(ctx, session, value)
EmailBan -> bans.unban_email(ctx, session, value, option.None)
PhoneBan -> bans.unban_phone(ctx, session, value)
}
case result {
Ok(_) ->
flash.redirect_with_success(
ctx,
config.route,
config.entity_name <> " " <> value <> " unbanned successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
config.route,
"Failed to unban " <> config.entity_name <> " " <> value,
)
}
}
option.Some("check"), Ok(value) -> {
let result = case ban_type {
IpBan -> bans.check_ip_ban(ctx, session, value)
EmailBan -> bans.check_email_ban(ctx, session, value)
PhoneBan -> bans.check_phone_ban(ctx, session, value)
}
case result {
Ok(response) if response.banned ->
flash.redirect_with_info(
ctx,
config.route,
config.entity_name <> " " <> value <> " is banned",
)
Ok(_) ->
flash.redirect_with_info(
ctx,
config.route,
config.entity_name <> " " <> value <> " is NOT banned",
)
Error(_) ->
flash.redirect_with_error(
ctx,
config.route,
"Error checking ban status",
)
}
}
_, _ -> wisp.redirect(web.prepend_base_path(ctx, config.route))
}
}

View File

@@ -0,0 +1,336 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/codes
import fluxer_admin/api/common
import fluxer_admin/components/flash
import fluxer_admin/components/layout
import fluxer_admin/components/slider_control
import fluxer_admin/components/ui
import fluxer_admin/constants
import fluxer_admin/web.{type Context, type Session}
import gleam/int
import gleam/list
import gleam/option
import gleam/string
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Request, type Response}
const max_beta_codes = 100
const default_count = 10
pub fn view(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
admin_acls: List(String),
) -> Response {
render_page(
ctx,
session,
current_admin,
flash_data,
admin_acls,
default_count,
option.None,
option.None,
)
}
fn render_page(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
admin_acls: List(String),
selected_count: Int,
generation_result: option.Option(flash.Flash),
generated_codes: option.Option(List(String)),
) -> Response {
let has_permission =
acl.has_permission(admin_acls, constants.acl_beta_codes_generate)
let content = case has_permission {
True ->
render_generator_card(generated_codes, generation_result, selected_count)
False -> render_access_denied()
}
let html =
layout.page(
"Beta Codes",
"beta-codes",
ctx,
session,
current_admin,
flash_data,
content,
)
wisp.html_response(element.to_document_string(html), 200)
}
fn render_generator_card(
generated_codes: option.Option(List(String)),
generation_result: option.Option(flash.Flash),
selected_count: Int,
) -> element.Element(a) {
let codes_value = case generated_codes {
option.Some(codes) -> string.join(codes, "\n")
option.None -> ""
}
let status_view = flash.view(generation_result)
h.div([a.class("max-w-7xl mx-auto space-y-6")], [
h.div([a.class("space-y-6")], [
ui.card(ui.PaddingMedium, [
h.div([a.class("space-y-2")], [
h.h1([a.class("text-2xl font-semibold text-neutral-900")], [
element.text("Generate Beta Codes"),
]),
]),
status_view,
h.form(
[
a.id("beta-form"),
a.class("space-y-4"),
a.method("POST"),
a.action("?action=generate"),
],
[
h.div([a.class("space-y-4")], [
h.div([a.class("flex items-center justify-between")], [
h.label([a.class("text-sm font-medium text-neutral-800")], [
element.text("How many codes"),
]),
h.span([a.class("text-xs text-neutral-500")], [
element.text("Range: 1-" <> int.to_string(max_beta_codes)),
]),
]),
h.div(
[a.class("space-y-4")],
list.append(
slider_control.range_slider_section(
"beta-count-slider",
"beta-count-value",
1,
max_beta_codes,
selected_count,
),
[
h.p([a.class("text-xs text-neutral-500")], [
element.text(
"Adjust the slider to pick the number of beta codes you need, then submit to generate them.",
),
]),
h.button(
[
a.type_("submit"),
a.class(
"px-4 py-2 bg-neutral-900 text-white rounded-lg text-sm font-medium hover:bg-neutral-800 transition-colors",
),
],
[element.text("Generate Beta Codes")],
),
],
),
),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-800")], [
element.text("Generated Codes"),
]),
h.textarea(
[
a.readonly(True),
a.attribute("rows", "10"),
a.class(
"w-full border border-neutral-200 rounded-lg px-4 py-3 text-sm text-neutral-900 bg-neutral-50 focus:outline-none focus:ring-2 focus:ring-neutral-900",
),
a.placeholder(
"Code output will appear here after generation.",
),
],
codes_value,
),
h.p([a.class("text-xs text-neutral-500")], [
element.text("Each code is newline separated for easy copying."),
]),
]),
],
),
slider_control.slider_sync_script(
"beta-count-slider",
"beta-count-value",
),
]),
]),
])
}
fn render_access_denied() -> element.Element(a) {
ui.card(ui.PaddingMedium, [
h.h1([a.class("text-2xl font-semibold text-neutral-900")], [
element.text("Beta Codes"),
]),
h.p([a.class("text-sm text-neutral-600")], [
element.text("You do not have permission to generate beta codes."),
]),
])
}
pub fn handle_action(
req: Request,
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
admin_acls: List(String),
action: option.Option(String),
) -> Response {
use form_data <- wisp.require_form(req)
case action {
option.Some("generate") ->
handle_generate(ctx, session, current_admin, admin_acls, form_data)
_ ->
render_page(
ctx,
session,
current_admin,
option.None,
admin_acls,
default_count,
option.Some(flash.Flash("Unknown action", flash.Error)),
option.None,
)
}
}
fn handle_generate(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
admin_acls: List(String),
form_data: wisp.FormData,
) -> Response {
case acl.has_permission(admin_acls, constants.acl_beta_codes_generate) {
False ->
render_page(
ctx,
session,
current_admin,
option.None,
admin_acls,
default_count,
option.Some(flash.Flash("Permission denied", flash.Error)),
option.None,
)
True ->
case parse_count(form_data) {
option.Some(value) ->
case value < 1 || value > max_beta_codes {
True ->
render_page(
ctx,
session,
current_admin,
option.None,
admin_acls,
value,
option.Some(flash.Flash(
"Count must be between 1 and "
<> int.to_string(max_beta_codes),
flash.Error,
)),
option.None,
)
False ->
case codes.generate_beta_codes(ctx, session, value) {
Ok(generated) ->
render_page(
ctx,
session,
current_admin,
option.None,
admin_acls,
value,
option.Some(flash.Flash(
"Generated "
<> int.to_string(list.length(generated))
<> " beta codes.",
flash.Success,
)),
option.Some(generated),
)
Error(err) ->
render_page(
ctx,
session,
current_admin,
option.None,
admin_acls,
value,
option.Some(flash.Flash(api_error_message(err), flash.Error)),
option.None,
)
}
}
option.None ->
render_page(
ctx,
session,
current_admin,
option.None,
admin_acls,
default_count,
option.Some(flash.Flash("Count is required", flash.Error)),
option.None,
)
}
}
}
fn parse_count(form_data: wisp.FormData) -> option.Option(Int) {
let raw =
list.key_find(form_data.values, "count")
|> option.from_result
case raw {
option.Some(str) ->
case int.parse(str) {
Ok(value) -> option.Some(value)
Error(_) -> option.None
}
option.None -> option.None
}
}
fn api_error_message(err: common.ApiError) -> String {
case err {
common.Unauthorized -> "Unauthorized"
common.Forbidden(message) -> message
common.NotFound -> "Not Found"
common.NetworkError -> "Network error"
common.ServerError -> "Server error"
}
}

View File

@@ -0,0 +1,736 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/bulk
import fluxer_admin/api/common
import fluxer_admin/components/deletion_days_script
import fluxer_admin/components/flash
import fluxer_admin/components/layout
import fluxer_admin/components/ui
import fluxer_admin/constants
import fluxer_admin/web.{type Context, type Session}
import gleam/int
import gleam/list
import gleam/option
import gleam/string
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Request, type Response}
pub fn view(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
admin_acls: List(String),
result: option.Option(bulk.BulkOperationResponse),
) -> Response {
let content =
h.div([a.class("space-y-6")], [
h.div([a.class("mb-6")], [ui.heading_page("Bulk Actions")]),
case result {
option.Some(response) -> render_result(response)
option.None -> element.none()
},
case acl.has_permission(admin_acls, "bulk:update_user_flags") {
True -> render_bulk_update_user_flags()
False -> element.none()
},
case acl.has_permission(admin_acls, "bulk:update_guild_features") {
True -> render_bulk_update_guild_features()
False -> element.none()
},
case acl.has_permission(admin_acls, "bulk:add_guild_members") {
True -> render_bulk_add_guild_members()
False -> element.none()
},
case acl.has_permission(admin_acls, "bulk:delete_users") {
True -> render_bulk_schedule_user_deletion()
False -> element.none()
},
])
let html =
layout.page(
"Bulk Actions",
"bulk-actions",
ctx,
session,
current_admin,
flash_data,
content,
)
wisp.html_response(element.to_document_string(html), 200)
}
fn render_custom_checkbox(
name: String,
value: String,
label: String,
) -> element.Element(a) {
ui.custom_checkbox(name, value, label, False, option.None)
}
fn render_result(response: bulk.BulkOperationResponse) {
let success_count = list.length(response.successful)
let fail_count = list.length(response.failed)
h.div([a.class("bg-white border border-neutral-200 rounded-lg p-6 mb-6")], [
ui.heading_card_with_margin("Operation Result"),
h.div([a.class("space-y-3")], [
h.div([a.class("text-sm")], [
h.span([a.class("text-sm font-medium text-green-600")], [
element.text("Successful: "),
]),
element.text(int.to_string(success_count)),
]),
h.div([a.class("text-sm")], [
h.span([a.class("text-sm font-medium text-red-600")], [
element.text("Failed: "),
]),
element.text(int.to_string(fail_count)),
]),
case list.is_empty(response.failed) {
True -> element.none()
False ->
h.div([a.class("mt-4")], [
h.h3([a.class("text-sm font-medium text-neutral-900 mb-2")], [
element.text("Errors:"),
]),
h.ul([a.class("space-y-1")], {
list.map(response.failed, fn(error) {
h.li([a.class("text-sm text-red-600")], [
element.text(error.id <> ": " <> error.error),
])
})
}),
])
},
]),
])
}
fn render_bulk_update_user_flags() {
let all_flags = constants.get_patchable_flags()
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Bulk Update User Flags"),
h.form(
[
a.method("POST"),
a.action("?action=bulk-update-user-flags"),
a.class("space-y-4"),
],
[
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("User IDs (one per line)"),
]),
h.textarea(
[
a.name("user_ids"),
a.placeholder("123456789\n987654321"),
a.required(True),
a.attribute("rows", "5"),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
],
"",
),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Flags to Add"),
]),
h.div([a.class("grid grid-cols-2 gap-3")], {
list.map(all_flags, fn(flag) {
render_custom_checkbox(
"add_flags[]",
int.to_string(flag.value),
flag.name,
)
})
}),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Flags to Remove"),
]),
h.div([a.class("grid grid-cols-2 gap-3")], {
list.map(all_flags, fn(flag) {
render_custom_checkbox(
"remove_flags[]",
int.to_string(flag.value),
flag.name,
)
})
}),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Audit Log Reason (optional)"),
]),
h.input([
a.type_("text"),
a.name("audit_log_reason"),
a.placeholder("Reason for this bulk operation"),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
]),
]),
ui.button(
"Update User Flags",
"submit",
ui.Primary,
ui.Medium,
ui.Full,
[],
),
],
),
])
}
fn render_bulk_update_guild_features() {
let all_features = constants.get_guild_features()
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Bulk Update Guild Features"),
h.form(
[
a.method("POST"),
a.action("?action=bulk-update-guild-features"),
a.class("space-y-4"),
],
[
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Guild IDs (one per line)"),
]),
h.textarea(
[
a.name("guild_ids"),
a.placeholder("123456789\n987654321"),
a.required(True),
a.attribute("rows", "5"),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
],
"",
),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Features to Add"),
]),
h.div([a.class("grid grid-cols-2 gap-3")], {
list.map(all_features, fn(feature) {
render_custom_checkbox(
"add_features[]",
feature.value,
feature.value,
)
})
}),
h.div([a.class("mt-3")], [
h.label([a.class("text-xs text-neutral-600 mb-1 block")], [
element.text("Custom features (comma-separated):"),
]),
h.input([
a.type_("text"),
a.name("custom_add_features"),
a.placeholder("CUSTOM_FEATURE_1, CUSTOM_FEATURE_2"),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
]),
]),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Features to Remove"),
]),
h.div([a.class("grid grid-cols-2 gap-3")], {
list.map(all_features, fn(feature) {
render_custom_checkbox(
"remove_features[]",
feature.value,
feature.value,
)
})
}),
h.div([a.class("mt-3")], [
h.label([a.class("text-xs text-neutral-600 mb-1 block")], [
element.text("Custom features (comma-separated):"),
]),
h.input([
a.type_("text"),
a.name("custom_remove_features"),
a.placeholder("CUSTOM_FEATURE_1, CUSTOM_FEATURE_2"),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
]),
]),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Audit Log Reason (optional)"),
]),
h.input([
a.type_("text"),
a.name("audit_log_reason"),
a.placeholder("Reason for this bulk operation"),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
]),
]),
ui.button(
"Update Guild Features",
"submit",
ui.Primary,
ui.Medium,
ui.Full,
[],
),
],
),
])
}
fn render_bulk_add_guild_members() {
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Bulk Add Guild Members"),
h.form(
[
a.method("POST"),
a.action("?action=bulk-add-guild-members"),
a.class("space-y-4"),
],
[
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Guild ID"),
]),
h.input([
a.type_("text"),
a.name("guild_id"),
a.placeholder("123456789"),
a.required(True),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
]),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("User IDs (one per line)"),
]),
h.textarea(
[
a.name("user_ids"),
a.placeholder("123456789\n987654321"),
a.required(True),
a.attribute("rows", "5"),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
],
"",
),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Audit Log Reason (optional)"),
]),
h.input([
a.type_("text"),
a.name("audit_log_reason"),
a.placeholder("Reason for this bulk operation"),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
]),
]),
h.button(
[
a.type_("submit"),
a.class(
"w-full px-4 py-2 bg-neutral-900 text-white rounded text-sm font-medium hover:bg-neutral-800 transition-colors",
),
],
[element.text("Add Members")],
),
],
),
])
}
fn render_bulk_schedule_user_deletion() {
let deletion_reasons = constants.get_deletion_reasons()
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Bulk Schedule User Deletion"),
h.form(
[
a.method("POST"),
a.action("?action=bulk-schedule-user-deletion"),
a.class("space-y-4"),
a.attribute(
"onsubmit",
"return confirm('Are you sure you want to schedule these users for deletion?')",
),
],
[
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("User IDs (one per line)"),
]),
h.textarea(
[
a.name("user_ids"),
a.placeholder("123456789\n987654321"),
a.required(True),
a.attribute("rows", "5"),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
],
"",
),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Deletion Reason"),
]),
h.select(
[
a.id("bulk-deletion-reason"),
a.name("reason_code"),
a.required(True),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
],
list.map(deletion_reasons, fn(reason) {
h.option([a.value(int.to_string(reason.0))], reason.1)
}),
),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Public Reason (optional)"),
]),
h.input([
a.type_("text"),
a.name("public_reason"),
a.placeholder("Terms of service violation"),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
]),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Days Until Deletion"),
]),
h.input([
a.type_("number"),
a.id("bulk-deletion-days"),
a.name("days_until_deletion"),
a.value("14"),
a.min("14"),
a.required(True),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
]),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Audit Log Reason (optional)"),
]),
h.input([
a.type_("text"),
a.name("audit_log_reason"),
a.placeholder("Reason for this bulk operation"),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
]),
]),
ui.button(
"Schedule Deletion",
"submit",
ui.Danger,
ui.Medium,
ui.Full,
[],
),
deletion_days_script.render(),
],
),
])
}
pub fn handle_action(
req: Request,
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
admin_acls: List(String),
action: option.Option(String),
) -> Response {
use form_data <- wisp.require_form(req)
case action {
option.Some("bulk-update-user-flags") -> {
let user_ids_text =
list.key_find(form_data.values, "user_ids") |> option.from_result
let add_flags =
list.filter_map(form_data.values, fn(field) {
case field.0 {
"add_flags[]" -> Ok(field.1)
_ -> Error(Nil)
}
})
let remove_flags =
list.filter_map(form_data.values, fn(field) {
case field.0 {
"remove_flags[]" -> Ok(field.1)
_ -> Error(Nil)
}
})
let audit_log_reason =
list.key_find(form_data.values, "audit_log_reason")
|> option.from_result
case user_ids_text {
option.Some(text) -> {
let user_ids =
string.split(text, "\n")
|> list.map(string.trim)
|> list.filter(fn(id) { !string.is_empty(id) })
case
bulk.bulk_update_user_flags(
ctx,
session,
user_ids,
add_flags,
remove_flags,
audit_log_reason,
)
{
Ok(result) ->
view(
ctx,
session,
current_admin,
option.None,
admin_acls,
option.Some(result),
)
Error(_) ->
wisp.redirect(web.prepend_base_path(ctx, "/bulk-actions"))
}
}
option.None ->
wisp.redirect(web.prepend_base_path(ctx, "/bulk-actions"))
}
}
option.Some("bulk-update-guild-features") -> {
let guild_ids_text =
list.key_find(form_data.values, "guild_ids") |> option.from_result
let add_features =
list.filter_map(form_data.values, fn(field) {
case field.0 {
"add_features[]" -> Ok(field.1)
_ -> Error(Nil)
}
})
let remove_features =
list.filter_map(form_data.values, fn(field) {
case field.0 {
"remove_features[]" -> Ok(field.1)
_ -> Error(Nil)
}
})
let custom_add_features =
list.key_find(form_data.values, "custom_add_features")
|> option.from_result
|> option.unwrap("")
|> string.split(",")
|> list.map(string.trim)
|> list.filter(fn(s) { !string.is_empty(s) })
let custom_remove_features =
list.key_find(form_data.values, "custom_remove_features")
|> option.from_result
|> option.unwrap("")
|> string.split(",")
|> list.map(string.trim)
|> list.filter(fn(s) { !string.is_empty(s) })
let add_features = list.append(add_features, custom_add_features)
let remove_features = list.append(remove_features, custom_remove_features)
let audit_log_reason =
list.key_find(form_data.values, "audit_log_reason")
|> option.from_result
case guild_ids_text {
option.Some(text) -> {
let guild_ids =
string.split(text, "\n")
|> list.map(string.trim)
|> list.filter(fn(id) { !string.is_empty(id) })
case
bulk.bulk_update_guild_features(
ctx,
session,
guild_ids,
add_features,
remove_features,
audit_log_reason,
)
{
Ok(result) ->
view(
ctx,
session,
current_admin,
option.None,
admin_acls,
option.Some(result),
)
Error(_) ->
wisp.redirect(web.prepend_base_path(ctx, "/bulk-actions"))
}
}
option.None ->
wisp.redirect(web.prepend_base_path(ctx, "/bulk-actions"))
}
}
option.Some("bulk-add-guild-members") -> {
let guild_id =
list.key_find(form_data.values, "guild_id") |> option.from_result
let user_ids_text =
list.key_find(form_data.values, "user_ids") |> option.from_result
let audit_log_reason =
list.key_find(form_data.values, "audit_log_reason")
|> option.from_result
case guild_id, user_ids_text {
option.Some(gid), option.Some(text) -> {
let user_ids =
string.split(text, "\n")
|> list.map(string.trim)
|> list.filter(fn(id) { !string.is_empty(id) })
case
bulk.bulk_add_guild_members(
ctx,
session,
gid,
user_ids,
audit_log_reason,
)
{
Ok(result) ->
view(
ctx,
session,
current_admin,
option.None,
admin_acls,
option.Some(result),
)
Error(_) ->
wisp.redirect(web.prepend_base_path(ctx, "/bulk-actions"))
}
}
_, _ -> wisp.redirect(web.prepend_base_path(ctx, "/bulk-actions"))
}
}
option.Some("bulk-schedule-user-deletion") -> {
let user_ids_text =
list.key_find(form_data.values, "user_ids") |> option.from_result
let reason_code =
list.key_find(form_data.values, "reason_code")
|> option.from_result
|> option.then(fn(s) { int.parse(s) |> option.from_result })
let public_reason =
list.key_find(form_data.values, "public_reason") |> option.from_result
let days_until_deletion =
list.key_find(form_data.values, "days_until_deletion")
|> option.from_result
|> option.then(fn(s) { int.parse(s) |> option.from_result })
|> option.unwrap(30)
let audit_log_reason =
list.key_find(form_data.values, "audit_log_reason")
|> option.from_result
case user_ids_text, reason_code {
option.Some(text), option.Some(code) -> {
let user_ids =
string.split(text, "\n")
|> list.map(string.trim)
|> list.filter(fn(id) { !string.is_empty(id) })
case
bulk.bulk_schedule_user_deletion(
ctx,
session,
user_ids,
code,
public_reason,
days_until_deletion,
audit_log_reason,
)
{
Ok(result) ->
view(
ctx,
session,
current_admin,
option.None,
admin_acls,
option.Some(result),
)
Error(_) ->
wisp.redirect(web.prepend_base_path(ctx, "/bulk-actions"))
}
}
_, _ -> wisp.redirect(web.prepend_base_path(ctx, "/bulk-actions"))
}
}
_ -> wisp.redirect(web.prepend_base_path(ctx, "/bulk-actions"))
}
}

View File

@@ -0,0 +1,53 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common
import fluxer_admin/components/flash
import fluxer_admin/pages/ban_management_page
import fluxer_admin/web.{type Context, type Session}
import gleam/option
import wisp.{type Request, type Response}
pub fn view(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
) -> Response {
ban_management_page.view(
ctx,
session,
current_admin,
flash_data,
ban_management_page.EmailBan,
)
}
pub fn handle_action(
req: Request,
ctx: Context,
session: Session,
action: option.Option(String),
) -> Response {
ban_management_page.handle_action(
req,
ctx,
session,
ban_management_page.EmailBan,
action,
)
}

View File

@@ -0,0 +1,237 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/feature_flags
import fluxer_admin/components/errors
import fluxer_admin/components/flash
import fluxer_admin/components/layout
import fluxer_admin/components/ui
import fluxer_admin/constants
import fluxer_admin/web.{type Context, type Session, action}
import gleam/dict
import gleam/int
import gleam/list
import gleam/option
import gleam/result
import gleam/string
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Request, type Response}
pub fn view(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
admin_acls: List(String),
) -> Response {
let can_view = acl.has_permission(admin_acls, constants.acl_feature_flag_view)
let content = case can_view {
False ->
errors.api_error_view(
ctx,
common.Forbidden("Access denied"),
option.None,
option.None,
)
True -> {
case feature_flags.get_feature_flags(ctx, session) {
Ok(entries) -> render_page(ctx, flash_data, entries)
Error(err) -> errors.api_error_view(ctx, err, option.None, option.None)
}
}
}
let html =
layout.page(
"Feature Flags",
"feature-flags",
ctx,
session,
current_admin,
flash_data,
content,
)
wisp.html_response(element.to_document_string(html), 200)
}
fn render_page(
ctx: Context,
flash_data: option.Option(flash.Flash),
entries: List(#(String, feature_flags.FeatureFlagConfig)),
) -> element.Element(a) {
let config_map = build_config_map(entries)
h.div([a.class("space-y-6")], [
ui.heading_page("Feature Flags"),
flash.view(flash_data),
h.div(
[a.class("space-y-6")],
list.map(constants.get_feature_flags(), fn(flag) {
let guild_ids = case dict.get(config_map, flag.id) {
Ok(ids) -> ids
Error(_) -> []
}
render_flag_card(ctx, flag, guild_ids)
}),
),
])
}
fn render_flag_card(
ctx: Context,
flag: constants.FeatureFlag,
guild_ids: List(String),
) -> element.Element(a) {
let guild_text = format_guild_list(guild_ids)
h.div([a.class("space-y-4")], [
ui.card(ui.PaddingMedium, [
h.div([a.class("space-y-4")], [
h.div([a.class("flex items-center justify-between")], [
h.div([a.class("space-y-1")], [
h.h3([a.class("text-lg font-semibold text-neutral-900")], [
element.text(flag.name),
]),
h.p([a.class("text-sm text-neutral-600")], [
element.text(flag.description),
]),
]),
h.span([a.class("text-xs uppercase tracking-wide text-neutral-500")], [
element.text(
"Guilds "
<> int.to_string(list.length(guild_ids))
<> " configured",
),
]),
]),
h.form(
[
a.method("POST"),
action(ctx, "/feature-flags?action=update"),
a.class("space-y-4"),
],
[
h.input([
a.type_("hidden"),
a.name("flag_id"),
a.value(flag.id),
]),
h.div([a.class("space-y-1")], [
h.label([a.class("text-sm font-medium text-neutral-700")], [
element.text("Guild IDs"),
]),
h.textarea(
[
a.name("guild_ids"),
a.attribute("rows", "3"),
a.class(
"w-full border border-neutral-300 rounded text-sm px-3 py-2",
),
],
guild_text,
),
h.p([a.class("text-xs text-neutral-500")], [
element.text(
"Comma-separated guild IDs that receive this feature.",
),
]),
]),
h.div([a.class("text-right")], [
ui.button_primary("Save", "submit", []),
]),
],
),
]),
]),
])
}
fn build_config_map(
entries: List(#(String, feature_flags.FeatureFlagConfig)),
) -> dict.Dict(String, List(String)) {
list.fold(entries, dict.new(), fn(acc, entry) {
let #(flag, config) = entry
dict.insert(acc, flag, config.guild_ids)
})
}
fn format_guild_list(ids: List(String)) -> String {
case ids {
[] -> ""
[first, ..rest] -> list.fold(rest, first, fn(acc, id) { acc <> ", " <> id })
}
}
fn parse_guild_ids(raw: String) -> List(String) {
list.flatten(
list.map(string.split(string.replace(raw, "\r", ""), "\n"), fn(line) {
list.filter(
list.map(string.split(line, ","), fn(item) { string.trim(item) }),
fn(item) { item != "" },
)
}),
)
}
pub fn handle_action(
req: Request,
ctx: Context,
session: Session,
admin_acls: List(String),
action_name: Result(String, Nil),
) -> Response {
case acl.has_permission(admin_acls, constants.acl_feature_flag_manage) {
False -> flash.redirect_with_error(ctx, "/feature-flags", "Access denied")
True ->
case action_name {
Ok("update") -> handle_update(req, ctx, session)
_ -> flash.redirect_with_error(ctx, "/feature-flags", "Unknown action")
}
}
}
fn handle_update(req: Request, ctx: Context, session: Session) -> Response {
use form_data <- wisp.require_form(req)
let flag_id =
list.key_find(form_data.values, "flag_id")
|> result.unwrap("")
let guild_input =
list.key_find(form_data.values, "guild_ids")
|> result.unwrap("")
let guild_ids = parse_guild_ids(guild_input)
case feature_flags.update_feature_flag(ctx, session, flag_id, guild_ids) {
Ok(_) ->
flash.redirect_with_success(ctx, "/feature-flags", "Feature flag updated")
Error(_) ->
flash.redirect_with_error(
ctx,
"/feature-flags",
"Failed to update feature flag",
)
}
}

View File

@@ -0,0 +1,916 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common
import fluxer_admin/api/system
import fluxer_admin/avatar
import fluxer_admin/components/flash
import fluxer_admin/components/layout
import fluxer_admin/components/ui
import fluxer_admin/web.{
type Context, type Session, action, href, prepend_base_path,
}
import gleam/float
import gleam/int
import gleam/list
import gleam/option
import gleam/string
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Request, type Response}
pub fn view(
_req: Request,
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
admin_acls: List(String),
result: option.Option(Int),
) -> Response {
let node_stats_result = system.get_node_stats(ctx, session)
let guild_stats_result = system.get_guild_memory_stats(ctx, session, 100)
let content = case node_stats_result, guild_stats_result {
Ok(node_stats), Ok(guild_stats) ->
render_success(
ctx,
admin_acls,
option.Some(node_stats),
guild_stats.guilds,
result,
)
_, Ok(guild_stats) ->
render_success(ctx, admin_acls, option.None, guild_stats.guilds, result)
_, Error(common.Unauthorized) -> render_error(ctx, "Unauthorized")
_, Error(common.Forbidden(message)) -> render_error(ctx, message)
_, Error(common.NotFound) -> render_error(ctx, "Not found")
_, Error(common.ServerError) -> render_error(ctx, "Server error")
_, Error(common.NetworkError) -> render_error(ctx, "Network error")
}
let html =
layout.page(
"Gateway",
"gateway",
ctx,
session,
current_admin,
flash_data,
content,
)
wisp.html_response(element.to_document_string(html), 200)
}
pub fn handle_action(
req: Request,
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
admin_acls: List(String),
) -> Response {
let flash_data = flash.from_request(req)
let result = case system.reload_all_guilds(ctx, session, []) {
Ok(count) -> option.Some(count)
Error(_) -> option.None
}
view(req, ctx, session, current_admin, flash_data, admin_acls, result)
}
fn render_error(_ctx: Context, message: String) {
ui.stack("6", [
ui.heading_page("Gateway"),
h.div(
[a.class("bg-red-50 border border-red-200 rounded-lg p-6 text-center")],
[h.p([a.class("text-red-800")], [element.text(message)])],
),
])
}
fn render_success(
ctx: Context,
admin_acls: List(String),
node_stats: option.Option(system.NodeStats),
guilds: List(system.ProcessMemoryStats),
result: option.Option(Int),
) {
let can_reload_all =
list.contains(admin_acls, "gateway:reload_all")
|| list.contains(admin_acls, "*")
h.div([], [
ui.flex_row_between([
ui.heading_page("Gateway"),
case can_reload_all {
True ->
h.form([a.method("POST"), action(ctx, "/gateway?action=reload_all")], [
ui.button_primary("Reload All Guilds", "submit", [
a.attribute(
"onclick",
"return confirm('Are you sure you want to reload all guilds in memory? This may take several minutes.');",
),
]),
])
False -> element.none()
},
]),
case result {
option.Some(count) ->
h.div(
[
a.class(
"mb-6 bg-green-50 border border-green-200 rounded-lg p-4 text-green-800",
),
],
[
element.text(
"Successfully reloaded " <> int.to_string(count) <> " guilds!",
),
],
)
option.None -> element.none()
},
case node_stats {
option.Some(stats) -> render_node_stats(ctx, stats)
option.None -> element.none()
},
h.div([a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")], [
h.div([a.class("p-6 border-b border-neutral-200")], [
ui.heading_section("Guild Memory Leaderboard (Top 100)"),
ui.text_small_muted(
"Guilds ranked by memory usage, showing the top 100 consumers",
),
]),
render_guild_table(ctx, guilds),
]),
])
}
fn render_guild_table(ctx: Context, guilds: List(system.ProcessMemoryStats)) {
case list.is_empty(guilds) {
True ->
h.div([a.class("p-6 text-center text-neutral-600")], [
element.text("No guilds in memory"),
])
False ->
h.div([a.class("overflow-x-auto")], [
h.table([a.class("w-full")], [
h.thead([a.class("bg-neutral-50 border-b border-neutral-200")], [
h.tr([], [
h.th(
[
a.class(
"px-6 py-3 text-left text-xs text-neutral-600 uppercase tracking-wider",
),
],
[element.text("Rank")],
),
h.th(
[
a.class(
"px-6 py-3 text-left text-xs text-neutral-600 uppercase tracking-wider",
),
],
[element.text("Guild")],
),
h.th(
[
a.class(
"px-6 py-3 text-right text-xs text-neutral-600 uppercase tracking-wider",
),
],
[element.text("RAM Usage")],
),
h.th(
[
a.class(
"px-6 py-3 text-right text-xs text-neutral-600 uppercase tracking-wider",
),
],
[element.text("Members")],
),
h.th(
[
a.class(
"px-6 py-3 text-right text-xs text-neutral-600 uppercase tracking-wider",
),
],
[element.text("Sessions")],
),
h.th(
[
a.class(
"px-6 py-3 text-right text-xs text-neutral-600 uppercase tracking-wider",
),
],
[element.text("Presences")],
),
]),
]),
h.tbody(
[a.class("divide-y divide-neutral-200")],
list.index_map(guilds, fn(guild, index) {
render_guild_row(ctx, guild, index)
}),
),
]),
])
}
}
fn render_guild_row(ctx: Context, guild: system.ProcessMemoryStats, index: Int) {
let rank = index + 1
h.tr([a.class("hover:bg-neutral-50 transition-colors")], [
h.td(
[
a.class(
"px-6 py-4 whitespace-nowrap text-sm font-medium text-neutral-900",
),
],
[element.text("#" <> int.to_string(rank))],
),
h.td([a.class("px-6 py-4 whitespace-nowrap")], [
case guild.guild_id {
option.Some(guild_id) ->
h.a(
[
href(ctx, "/guilds/" <> guild_id),
a.class("flex items-center gap-2"),
],
[
case
avatar.get_guild_icon_url(
ctx.media_endpoint,
guild_id,
guild.guild_icon,
True,
)
{
option.Some(icon_url) ->
h.img([
a.src(icon_url),
a.alt(guild.guild_name),
a.class("w-10 h-10 rounded-full"),
])
option.None ->
h.div(
[
a.class(
"w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center text-sm font-medium text-neutral-600",
),
],
[
element.text(
guild.guild_name
|> get_first_char,
),
],
)
},
h.div([], [
h.div([a.class("text-sm font-medium text-neutral-900")], [
element.text(guild.guild_name),
]),
h.div([a.class("text-xs text-neutral-500")], [
element.text(guild_id),
]),
]),
],
)
option.None ->
h.div([a.class("flex items-center gap-2")], [
h.div(
[
a.class(
"w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center text-sm font-medium text-neutral-600",
),
],
[element.text("?")],
),
h.span([a.class("text-sm text-neutral-600")], [
element.text(guild.guild_name),
]),
])
},
]),
h.td(
[
a.class(
"px-6 py-4 whitespace-nowrap text-sm text-neutral-900 text-right text-sm font-medium",
),
],
[element.text(format_memory(guild.memory_mb))],
),
h.td(
[
a.class(
"px-6 py-4 whitespace-nowrap text-sm text-neutral-900 text-right",
),
],
[element.text(format_number(guild.member_count))],
),
h.td(
[
a.class(
"px-6 py-4 whitespace-nowrap text-sm text-neutral-900 text-right",
),
],
[element.text(format_number(guild.session_count))],
),
h.td(
[
a.class(
"px-6 py-4 whitespace-nowrap text-sm text-neutral-900 text-right",
),
],
[element.text(format_number(guild.presence_count))],
),
])
}
fn get_first_char(s: String) -> String {
case s {
"" -> "?"
_ -> {
let assert Ok(first) = s |> string.first
first
}
}
}
fn format_number(n: Int) -> String {
let s = int.to_string(n)
let len = string.length(s)
case len {
_ if len <= 3 -> s
_ -> {
let groups = reverse_groups(s, [])
string.join(list.reverse(groups), ",")
}
}
}
fn reverse_groups(s: String, acc: List(String)) -> List(String) {
let len = string.length(s)
case len {
0 -> acc
_ if len <= 3 -> [s, ..acc]
_ -> {
let group = string.slice(s, len - 3, 3)
let rest = string.slice(s, 0, len - 3)
reverse_groups(rest, [group, ..acc])
}
}
}
fn format_memory(memory_mb: Float) -> String {
case memory_mb {
_ if memory_mb <. 1.0 -> {
let kb = memory_mb *. 1024.0
float_to_string_rounded(kb, 2) <> " KB"
}
_ if memory_mb <. 1024.0 -> {
float_to_string_rounded(memory_mb, 2) <> " MB"
}
_ -> {
let gb = memory_mb /. 1024.0
float_to_string_rounded(gb, 2) <> " GB"
}
}
}
fn float_to_string_rounded(value: Float, decimals: Int) -> String {
let multiplier = case decimals {
0 -> 1.0
1 -> 10.0
2 -> 100.0
3 -> 1000.0
_ -> 100.0
}
let rounded = float.round(value *. multiplier) |> int.to_float
let result = rounded /. multiplier
case decimals {
0 -> {
let int_value = float.round(result)
int.to_string(int_value)
}
_ -> {
let str = float.to_string(result)
case string.contains(str, ".") {
True -> str
False -> str <> ".0"
}
}
}
}
fn render_node_stats(ctx: Context, stats: system.NodeStats) {
h.div([], [
h.div(
[a.class("bg-white border border-neutral-200 rounded-lg shadow-sm mb-6")],
[
h.div([a.class("p-6")], [
ui.heading_section("Gateway Statistics"),
h.div(
[
a.class(
"grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mt-4",
),
],
[
render_stat_card(ctx, "Sessions", format_number(stats.sessions)),
render_stat_card(ctx, "Guilds", format_number(stats.guilds)),
render_stat_card(ctx, "Presences", format_number(stats.presences)),
render_stat_card(ctx, "Calls", format_number(stats.calls)),
render_stat_card(
ctx,
"Total RAM",
format_memory(int.to_float(stats.memory_total) /. 1_024_000.0),
),
],
),
]),
],
),
render_gateway_charts(ctx),
])
}
fn render_gateway_charts(ctx: Context) {
case ctx.metrics_endpoint {
option.Some(_) -> render_gateway_charts_content(ctx)
option.None -> element.none()
}
}
fn render_gateway_charts_content(ctx: Context) {
let proxy_endpoint = prepend_base_path(ctx, "/api/metrics")
h.div([a.class("space-y-6 mb-6")], [
h.div([a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")], [
h.div([a.class("p-6")], [
ui.heading_section("Process Counts Over Time"),
ui.text_small_muted(
"Historical view of active sessions, guilds, presences, and calls",
),
h.div([a.class("mt-4")], [
element.element(
"canvas",
[a.id("processCountsChart"), a.attribute("height", "250")],
[],
),
]),
]),
]),
h.div([a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")], [
h.div([a.class("p-6")], [
ui.heading_section("WebSocket Connection Activity"),
ui.text_small_muted(
"Connection and disconnection rates per reporting interval",
),
h.div([a.class("mt-4")], [
element.element(
"canvas",
[a.id("wsConnectionChart"), a.attribute("height", "250")],
[],
),
]),
]),
]),
h.div([a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")], [
h.div([a.class("p-6")], [
ui.heading_section("Heartbeat Health"),
ui.text_small_muted(
"Heartbeat success and failure counts per reporting interval",
),
h.div([a.class("mt-4")], [
element.element(
"canvas",
[a.id("heartbeatChart"), a.attribute("height", "250")],
[],
),
]),
]),
]),
h.div([a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")], [
h.div([a.class("p-6")], [
ui.heading_section("Session Resume Activity"),
ui.text_small_muted(
"Resume success and failure counts per reporting interval",
),
h.div([a.class("mt-4")], [
element.element(
"canvas",
[a.id("resumeChart"), a.attribute("height", "250")],
[],
),
]),
]),
]),
h.div([a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")], [
h.div([a.class("p-6")], [
ui.heading_section("Rate Limiting Events"),
ui.text_small_muted(
"Identify rate limiting triggers per reporting interval",
),
h.div([a.class("mt-4")], [
element.element(
"canvas",
[a.id("rateLimitChart"), a.attribute("height", "250")],
[],
),
]),
]),
]),
h.div([a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")], [
h.div([a.class("p-6")], [
ui.heading_section("RPC Latency"),
ui.text_small_muted(
"API RPC call latency percentiles (p50, p95, p99) in milliseconds",
),
h.div([a.class("mt-4")], [
element.element(
"canvas",
[a.id("rpcLatencyChart"), a.attribute("height", "250")],
[],
),
]),
]),
]),
h.div([a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")], [
h.div([a.class("p-6")], [
ui.heading_section("Mailbox Sizes Over Time"),
ui.text_small_muted(
"GenServer message queue lengths - high values may indicate bottlenecks",
),
h.div([a.class("mt-4")], [
element.element(
"canvas",
[a.id("mailboxChart"), a.attribute("height", "250")],
[],
),
]),
]),
]),
h.div([a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")], [
h.div([a.class("p-6")], [
ui.heading_section("Cache Memory Over Time"),
ui.text_small_muted("Memory usage of presence cache and push cache"),
h.div([a.class("mt-4")], [
element.element(
"canvas",
[a.id("cacheMemoryChart"), a.attribute("height", "250")],
[],
),
]),
]),
]),
h.script([a.src("https://fluxerstatic.com/libs/chartjs/chart.min.js")], ""),
h.script([], render_gateway_charts_script(proxy_endpoint)),
])
}
fn render_gateway_charts_script(metrics_endpoint: String) -> String {
"
(async function() {
const endpoint = '" <> metrics_endpoint <> "';
if (!endpoint) return;
const formatBytes = (bytes) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const alignData = (data, timestamps) => {
const map = new Map(data.map(d => [d.timestamp, d.value]));
return timestamps.map(ts => map.get(ts) ?? null);
};
const formatTimeLabel = (ts) => {
const d = new Date(ts);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
try {
const [sessionsResp, guildsResp, presencesResp, callsResp] = await Promise.all([
fetch(endpoint + '/query?metric=gateway.sessions.count').then(r => r.json()),
fetch(endpoint + '/query?metric=gateway.guilds.count').then(r => r.json()),
fetch(endpoint + '/query?metric=gateway.presences.count').then(r => r.json()),
fetch(endpoint + '/query?metric=gateway.calls.count').then(r => r.json())
]);
const pcTimestamps = Array.from(new Set([
...sessionsResp.data.map(d => d.timestamp),
...guildsResp.data.map(d => d.timestamp),
...presencesResp.data.map(d => d.timestamp),
...callsResp.data.map(d => d.timestamp),
])).sort((a, b) => a - b);
if (pcTimestamps.length > 0) {
new Chart(document.getElementById('processCountsChart'), {
type: 'line',
data: {
labels: pcTimestamps.map(formatTimeLabel),
datasets: [
{ label: 'Sessions', data: alignData(sessionsResp.data, pcTimestamps), borderColor: 'rgb(59, 130, 246)', tension: 0.1, spanGaps: true },
{ label: 'Guilds', data: alignData(guildsResp.data, pcTimestamps), borderColor: 'rgb(34, 197, 94)', tension: 0.1, spanGaps: true },
{ label: 'Presences', data: alignData(presencesResp.data, pcTimestamps), borderColor: 'rgb(168, 85, 247)', tension: 0.1, spanGaps: true },
{ label: 'Calls', data: alignData(callsResp.data, pcTimestamps), borderColor: 'rgb(239, 68, 68)', tension: 0.1, spanGaps: true }
]
},
options: {
responsive: true,
scales: { y: { beginAtZero: true } },
plugins: { legend: { position: 'top' } }
}
});
}
} catch (e) {
console.error('Failed to load process counts chart:', e);
}
try {
const [connResp, disconnResp] = await Promise.all([
fetch(endpoint + '/query?metric=gateway.websocket.connections').then(r => r.json()),
fetch(endpoint + '/query?metric=gateway.websocket.disconnections').then(r => r.json())
]);
const wsTimestamps = Array.from(new Set([
...connResp.data.map(d => d.timestamp),
...disconnResp.data.map(d => d.timestamp),
])).sort((a, b) => a - b);
if (wsTimestamps.length > 0) {
new Chart(document.getElementById('wsConnectionChart'), {
type: 'line',
data: {
labels: wsTimestamps.map(formatTimeLabel),
datasets: [
{ label: 'Connections', data: alignData(connResp.data, wsTimestamps), borderColor: 'rgb(34, 197, 94)', tension: 0.1, spanGaps: true },
{ label: 'Disconnections', data: alignData(disconnResp.data, wsTimestamps), borderColor: 'rgb(239, 68, 68)', tension: 0.1, spanGaps: true }
]
},
options: {
responsive: true,
scales: { y: { beginAtZero: true, title: { display: true, text: 'Count' } } },
plugins: { legend: { position: 'top' } }
}
});
}
} catch (e) {
console.error('Failed to load WebSocket connection chart:', e);
}
try {
const [hbSuccessResp, hbFailResp] = await Promise.all([
fetch(endpoint + '/query?metric=gateway.heartbeat.success').then(r => r.json()),
fetch(endpoint + '/query?metric=gateway.heartbeat.failure').then(r => r.json())
]);
const hbTimestamps = Array.from(new Set([
...hbSuccessResp.data.map(d => d.timestamp),
...hbFailResp.data.map(d => d.timestamp),
])).sort((a, b) => a - b);
if (hbTimestamps.length > 0) {
new Chart(document.getElementById('heartbeatChart'), {
type: 'line',
data: {
labels: hbTimestamps.map(formatTimeLabel),
datasets: [
{ label: 'Success', data: alignData(hbSuccessResp.data, hbTimestamps), borderColor: 'rgb(34, 197, 94)', tension: 0.1, spanGaps: true },
{ label: 'Failure', data: alignData(hbFailResp.data, hbTimestamps), borderColor: 'rgb(239, 68, 68)', tension: 0.1, spanGaps: true }
]
},
options: {
responsive: true,
scales: { y: { beginAtZero: true, title: { display: true, text: 'Count' } } },
plugins: { legend: { position: 'top' } }
}
});
}
} catch (e) {
console.error('Failed to load heartbeat chart:', e);
}
try {
const [resumeSuccessResp, resumeFailResp] = await Promise.all([
fetch(endpoint + '/query?metric=gateway.resume.success').then(r => r.json()),
fetch(endpoint + '/query?metric=gateway.resume.failure').then(r => r.json())
]);
const resumeTimestamps = Array.from(new Set([
...resumeSuccessResp.data.map(d => d.timestamp),
...resumeFailResp.data.map(d => d.timestamp),
])).sort((a, b) => a - b);
if (resumeTimestamps.length > 0) {
new Chart(document.getElementById('resumeChart'), {
type: 'line',
data: {
labels: resumeTimestamps.map(formatTimeLabel),
datasets: [
{ label: 'Success', data: alignData(resumeSuccessResp.data, resumeTimestamps), borderColor: 'rgb(34, 197, 94)', tension: 0.1, spanGaps: true },
{ label: 'Failure', data: alignData(resumeFailResp.data, resumeTimestamps), borderColor: 'rgb(239, 68, 68)', tension: 0.1, spanGaps: true }
]
},
options: {
responsive: true,
scales: { y: { beginAtZero: true, title: { display: true, text: 'Count' } } },
plugins: { legend: { position: 'top' } }
}
});
}
} catch (e) {
console.error('Failed to load resume chart:', e);
}
try {
const rateLimitResp = await fetch(endpoint + '/query?metric=gateway.identify.rate_limited').then(r => r.json());
const rlTimestamps = rateLimitResp.data.map(d => d.timestamp).sort((a, b) => a - b);
if (rlTimestamps.length > 0) {
new Chart(document.getElementById('rateLimitChart'), {
type: 'bar',
data: {
labels: rlTimestamps.map(formatTimeLabel),
datasets: [
{ label: 'Rate Limited', data: alignData(rateLimitResp.data, rlTimestamps), backgroundColor: 'rgb(251, 146, 60)' }
]
},
options: {
responsive: true,
scales: { y: { beginAtZero: true, title: { display: true, text: 'Count' } } },
plugins: { legend: { position: 'top' } }
}
});
}
} catch (e) {
console.error('Failed to load rate limit chart:', e);
}
try {
const [p50Resp, p95Resp, p99Resp] = await Promise.all([
fetch(endpoint + '/query?metric=gateway.rpc.latency.p50').then(r => r.json()),
fetch(endpoint + '/query?metric=gateway.rpc.latency.p95').then(r => r.json()),
fetch(endpoint + '/query?metric=gateway.rpc.latency.p99').then(r => r.json())
]);
const latencyTimestamps = Array.from(new Set([
...p50Resp.data.map(d => d.timestamp),
...p95Resp.data.map(d => d.timestamp),
...p99Resp.data.map(d => d.timestamp),
])).sort((a, b) => a - b);
if (latencyTimestamps.length > 0) {
new Chart(document.getElementById('rpcLatencyChart'), {
type: 'line',
data: {
labels: latencyTimestamps.map(formatTimeLabel),
datasets: [
{ label: 'p50', data: alignData(p50Resp.data, latencyTimestamps), borderColor: 'rgb(34, 197, 94)', tension: 0.1, spanGaps: true },
{ label: 'p95', data: alignData(p95Resp.data, latencyTimestamps), borderColor: 'rgb(251, 146, 60)', tension: 0.1, spanGaps: true },
{ label: 'p99', data: alignData(p99Resp.data, latencyTimestamps), borderColor: 'rgb(239, 68, 68)', tension: 0.1, spanGaps: true }
]
},
options: {
responsive: true,
scales: { y: { beginAtZero: true, title: { display: true, text: 'Latency (ms)' } } },
plugins: { legend: { position: 'top' } }
}
});
}
} catch (e) {
console.error('Failed to load RPC latency chart:', e);
}
try {
const [smResp, gmResp, pmResp, cmResp, pushResp] = await Promise.all([
fetch(endpoint + '/query?metric=gateway.mailbox.session_manager').then(r => r.json()),
fetch(endpoint + '/query?metric=gateway.mailbox.guild_manager').then(r => r.json()),
fetch(endpoint + '/query?metric=gateway.mailbox.presence_manager').then(r => r.json()),
fetch(endpoint + '/query?metric=gateway.mailbox.call_manager').then(r => r.json()),
fetch(endpoint + '/query?metric=gateway.mailbox.push').then(r => r.json())
]);
const mbTimestamps = Array.from(new Set([
...smResp.data.map(d => d.timestamp),
...gmResp.data.map(d => d.timestamp),
...pmResp.data.map(d => d.timestamp),
...cmResp.data.map(d => d.timestamp),
...pushResp.data.map(d => d.timestamp),
])).sort((a, b) => a - b);
if (mbTimestamps.length > 0) {
new Chart(document.getElementById('mailboxChart'), {
type: 'line',
data: {
labels: mbTimestamps.map(formatTimeLabel),
datasets: [
{ label: 'Session Manager', data: alignData(smResp.data, mbTimestamps), borderColor: 'rgb(59, 130, 246)', tension: 0.1, spanGaps: true },
{ label: 'Guild Manager', data: alignData(gmResp.data, mbTimestamps), borderColor: 'rgb(34, 197, 94)', tension: 0.1, spanGaps: true },
{ label: 'Presence Manager', data: alignData(pmResp.data, mbTimestamps), borderColor: 'rgb(168, 85, 247)', tension: 0.1, spanGaps: true },
{ label: 'Call Manager', data: alignData(cmResp.data, mbTimestamps), borderColor: 'rgb(239, 68, 68)', tension: 0.1, spanGaps: true },
{ label: 'Push', data: alignData(pushResp.data, mbTimestamps), borderColor: 'rgb(251, 146, 60)', tension: 0.1, spanGaps: true }
]
},
options: {
responsive: true,
scales: { y: { beginAtZero: true, title: { display: true, text: 'Queue Length' } } },
plugins: { legend: { position: 'top' } }
}
});
}
} catch (e) {
console.error('Failed to load mailbox chart:', e);
}
try {
const [presenceCacheResp, pushMemResp] = await Promise.all([
fetch(endpoint + '/query?metric=gateway.memory.presence_cache').then(r => r.json()),
fetch(endpoint + '/query?metric=gateway.memory.push').then(r => r.json())
]);
const memTimestamps = Array.from(new Set([
...presenceCacheResp.data.map(d => d.timestamp),
...pushMemResp.data.map(d => d.timestamp),
])).sort((a, b) => a - b);
if (memTimestamps.length > 0) {
new Chart(document.getElementById('cacheMemoryChart'), {
type: 'line',
data: {
labels: memTimestamps.map(formatTimeLabel),
datasets: [
{ label: 'Presence Cache', data: alignData(presenceCacheResp.data, memTimestamps), borderColor: 'rgb(59, 130, 246)', tension: 0.1, spanGaps: true },
{ label: 'Push Cache', data: alignData(pushMemResp.data, memTimestamps), borderColor: 'rgb(34, 197, 94)', tension: 0.1, spanGaps: true }
]
},
options: {
responsive: true,
scales: {
y: {
beginAtZero: true,
title: { display: true, text: 'Bytes' },
ticks: { callback: function(value) { return formatBytes(value); } }
}
},
plugins: {
legend: { position: 'top' },
tooltip: { callbacks: { label: function(context) { return context.dataset.label + ': ' + formatBytes(context.raw); } } }
}
}
});
}
} catch (e) {
console.error('Failed to load cache memory chart:', e);
}
})();
"
}
fn render_stat_card(_ctx: Context, label: String, value: String) {
h.div(
[
a.class("bg-neutral-50 rounded-lg p-4 border border-neutral-200"),
],
[
h.div(
[
a.class("text-xs text-neutral-600 uppercase tracking-wider mb-1"),
],
[
element.text(label),
],
),
h.div([a.class("text-base font-semibold text-neutral-900")], [
element.text(value),
]),
],
)
}

View File

@@ -0,0 +1,393 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/codes
import fluxer_admin/api/common
import fluxer_admin/components/flash
import fluxer_admin/components/layout
import fluxer_admin/components/slider_control
import fluxer_admin/components/ui
import fluxer_admin/constants
import fluxer_admin/web.{type Context, type Session}
import gleam/int
import gleam/list
import gleam/option
import gleam/string
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Request, type Response}
const max_gift_codes = 100
const default_gift_count = 10
const gift_product_options = [
#("gift_1_month", "Gift - 1 Month subscription"),
#("gift_1_year", "Gift - 1 Year subscription"),
#("gift_visionary", "Gift - Visionary lifetime"),
]
pub fn view(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
admin_acls: List(String),
) -> Response {
render_page(
ctx,
session,
current_admin,
flash_data,
admin_acls,
default_gift_count,
option.None,
option.None,
)
}
fn render_page(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
admin_acls: List(String),
selected_count: Int,
generation_result: option.Option(flash.Flash),
generated_codes: option.Option(List(String)),
) -> Response {
let has_permission =
acl.has_permission(admin_acls, constants.acl_gift_codes_generate)
let content = case has_permission {
True ->
render_generator_card(generated_codes, generation_result, selected_count)
False -> render_access_denied()
}
let html =
layout.page(
"Gift Codes",
"gift-codes",
ctx,
session,
current_admin,
flash_data,
content,
)
wisp.html_response(element.to_document_string(html), 200)
}
fn render_generator_card(
generated_codes: option.Option(List(String)),
generation_result: option.Option(flash.Flash),
selected_count: Int,
) -> element.Element(a) {
let codes_value = case generated_codes {
option.Some(codes) -> string.join(codes, "\n")
option.None -> ""
}
let status_view = flash.view(generation_result)
h.div([a.class("max-w-7xl mx-auto space-y-6")], [
h.div([a.class("space-y-6")], [
ui.card(ui.PaddingMedium, [
h.div([a.class("space-y-2")], [
h.h1([a.class("text-2xl font-semibold text-neutral-900")], [
element.text("Generate Gift Codes"),
]),
]),
status_view,
h.form(
[
a.id("gift-form"),
a.class("space-y-4"),
a.method("POST"),
a.action("?action=generate"),
],
[
h.div([a.class("space-y-4")], [
h.div([a.class("flex items-center justify-between")], [
h.label([a.class("text-sm font-medium text-neutral-800")], [
element.text("How many codes"),
]),
h.span([a.class("text-xs text-neutral-500")], [
element.text("Range: 1-" <> int.to_string(max_gift_codes)),
]),
]),
h.div(
[a.class("space-y-4")],
list.append(
slider_control.range_slider_section(
"gift-count-slider",
"gift-count-value",
1,
max_gift_codes,
selected_count,
),
[
h.p([a.class("text-xs text-neutral-500")], [
element.text(
"Select the number of gift codes to generate.",
),
]),
h.div([a.class("space-y-1")], [
h.label(
[a.class("text-sm font-medium text-neutral-800")],
[
element.text("Product"),
],
),
h.select(
[
a.name("product_type"),
a.class(
"w-full rounded-lg border border-neutral-200 px-3 py-2 text-sm text-neutral-900 focus:outline-none focus:ring-2 focus:ring-neutral-900",
),
],
list.map(gift_product_options, fn(option) {
let value = option.0
let label = option.1
h.option([a.value(value)], label)
}),
),
h.p([a.class("text-xs text-neutral-500")], [
element.text(
"Generated codes are rendered as https://fluxer.gift/<code>.",
),
]),
]),
h.button(
[
a.type_("submit"),
a.class(
"px-4 py-2 bg-neutral-900 text-white rounded-lg text-sm font-medium hover:bg-neutral-800 transition-colors",
),
],
[element.text("Generate Gift Codes")],
),
],
),
),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-800")], [
element.text("Generated URLs"),
]),
h.textarea(
[
a.readonly(True),
a.attribute("rows", "10"),
a.class(
"w-full border border-neutral-200 rounded-lg px-4 py-3 text-sm text-neutral-900 bg-neutral-50 focus:outline-none focus:ring-2 focus:ring-neutral-900",
),
a.placeholder(
"Full gift URLs will appear here after generation.",
),
],
codes_value,
),
h.p([a.class("text-xs text-neutral-500")], [
element.text("Copy one URL per line when sharing codes."),
]),
]),
],
),
slider_control.slider_sync_script(
"gift-count-slider",
"gift-count-value",
),
]),
]),
])
}
fn render_access_denied() -> element.Element(a) {
ui.card(ui.PaddingMedium, [
h.h1([a.class("text-2xl font-semibold text-neutral-900")], [
element.text("Gift Codes"),
]),
h.p([a.class("text-sm text-neutral-600")], [
element.text("You do not have permission to generate gift codes."),
]),
])
}
pub fn handle_action(
req: Request,
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
admin_acls: List(String),
action: option.Option(String),
) -> Response {
use form_data <- wisp.require_form(req)
case action {
option.Some("generate") ->
handle_generate(ctx, session, current_admin, admin_acls, form_data)
_ ->
render_page(
ctx,
session,
current_admin,
option.None,
admin_acls,
default_gift_count,
option.Some(flash.Flash("Unknown action", flash.Error)),
option.None,
)
}
}
fn handle_generate(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
admin_acls: List(String),
form_data: wisp.FormData,
) -> Response {
case acl.has_permission(admin_acls, constants.acl_gift_codes_generate) {
False ->
render_page(
ctx,
session,
current_admin,
option.None,
admin_acls,
default_gift_count,
option.Some(flash.Flash("Permission denied", flash.Error)),
option.None,
)
True ->
case parse_count(form_data), parse_product_type(form_data) {
option.Some(value), option.Some(product) ->
case value < 1 || value > max_gift_codes {
True ->
render_page(
ctx,
session,
current_admin,
option.None,
admin_acls,
value,
option.Some(flash.Flash(
"Count must be between 1 and "
<> int.to_string(max_gift_codes),
flash.Error,
)),
option.None,
)
False ->
case codes.generate_gift_codes(ctx, session, value, product) {
Ok(generated) ->
render_page(
ctx,
session,
current_admin,
option.None,
admin_acls,
value,
option.Some(flash.Flash(
"Generated "
<> int.to_string(list.length(generated))
<> " gift codes.",
flash.Success,
)),
option.Some(generated),
)
Error(err) ->
render_page(
ctx,
session,
current_admin,
option.None,
admin_acls,
value,
option.Some(flash.Flash(api_error_message(err), flash.Error)),
option.None,
)
}
}
option.None, _ ->
render_page(
ctx,
session,
current_admin,
option.None,
admin_acls,
default_gift_count,
option.Some(flash.Flash("Count is required", flash.Error)),
option.None,
)
option.Some(value), option.None ->
render_page(
ctx,
session,
current_admin,
option.None,
admin_acls,
value,
option.Some(flash.Flash("Product type is required", flash.Error)),
option.None,
)
}
}
}
fn parse_count(form_data: wisp.FormData) -> option.Option(Int) {
let value =
list.key_find(form_data.values, "count")
|> option.from_result
case value {
option.Some(str) ->
case int.parse(str) {
Ok(num) -> option.Some(num)
Error(_) -> option.None
}
option.None -> option.None
}
}
fn parse_product_type(form_data: wisp.FormData) -> option.Option(String) {
let raw =
list.key_find(form_data.values, "product_type")
|> option.from_result
case raw {
option.Some(value) ->
case list.any(gift_product_options, fn(option) { option.0 == value }) {
True -> option.Some(value)
False -> option.None
}
option.None -> option.None
}
}
fn api_error_message(err: common.ApiError) -> String {
case err {
common.Unauthorized -> "Unauthorized"
common.Forbidden(message) -> message
common.NotFound -> "Not Found"
common.NetworkError -> "Network error"
common.ServerError -> "Server error"
}
}

View File

@@ -0,0 +1,183 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/components/ui
import fluxer_admin/constants
import fluxer_admin/web.{type Context, action}
import gleam/int
import gleam/list
import gleam/option
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
pub fn render_features_form(
ctx: Context,
current_features: List(String),
guild_id: String,
) {
let all_features = constants.get_guild_features()
let known_feature_values = list.map(all_features, fn(f) { f.value })
let custom_features =
list.filter(current_features, fn(f) {
!list.contains(known_feature_values, f)
})
h.form(
[
a.method("POST"),
action(
ctx,
"/guilds/" <> guild_id <> "?action=update-features&tab=features",
),
a.id("features-form"),
],
[
h.div(
[a.class("space-y-3")],
list.map(all_features, fn(feature) {
render_feature_checkbox(feature, current_features)
}),
),
h.div([a.class("mt-6 pt-6 border-t border-neutral-200")], [
h.label([a.class("block")], [
h.span([a.class("text-sm text-neutral-900 mb-2 block")], [
element.text("Custom Features"),
]),
h.p([a.class("text-xs text-neutral-600 mb-2")], [
element.text(
"Enter custom feature strings separated by commas (e.g., CUSTOM_FEATURE_1, CUSTOM_FEATURE_2)",
),
]),
h.input([
a.type_("text"),
a.name("custom_features"),
a.placeholder("CUSTOM_FEATURE_1, CUSTOM_FEATURE_2"),
a.value(
list.fold(custom_features, "", fn(acc, f) {
case acc {
"" -> f
_ -> acc <> ", " <> f
}
}),
),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-neutral-900",
),
a.attribute(
"onchange",
"document.getElementById('features-save-button').classList.remove('hidden')",
),
]),
]),
]),
h.div(
[
a.class("mt-6 pt-6 border-t border-neutral-200"),
a.id("features-save-button"),
],
[
ui.button_primary("Save Changes", "submit", []),
],
),
],
)
}
pub fn render_feature_checkbox(
feature: constants.GuildFeature,
current_features: List(String),
) {
let is_checked = list.contains(current_features, feature.value)
let onchange_script = case feature.value {
"UNAVAILABLE_FOR_EVERYONE" ->
"if(this.checked){const other=document.querySelector('input[value=\"UNAVAILABLE_FOR_EVERYONE_BUT_STAFF\"]');if(other)other.checked=false;}document.getElementById('features-save-button').classList.remove('hidden')"
"UNAVAILABLE_FOR_EVERYONE_BUT_STAFF" ->
"if(this.checked){const other=document.querySelector('input[value=\"UNAVAILABLE_FOR_EVERYONE\"]');if(other)other.checked=false;}document.getElementById('features-save-button').classList.remove('hidden')"
_ ->
"document.getElementById('features-save-button').classList.remove('hidden')"
}
ui.custom_checkbox(
"features[]",
feature.value,
feature.value,
is_checked,
option.Some(onchange_script),
)
}
pub fn render_disabled_operations_form(
ctx: Context,
current_disabled_operations: Int,
guild_id: String,
) {
let all_operations = constants.get_disabled_operations()
h.form(
[
a.method("POST"),
action(
ctx,
"/guilds/"
<> guild_id
<> "?action=update-disabled-operations&tab=settings",
),
a.id("disabled-ops-form"),
],
[
h.div(
[a.class("space-y-3")],
list.map(all_operations, fn(operation) {
render_disabled_operation_checkbox(
operation,
current_disabled_operations,
)
}),
),
h.div(
[
a.class("mt-6 pt-6 border-t border-neutral-200 hidden"),
a.id("disabled-ops-save-button"),
],
[
ui.button_primary("Save Changes", "submit", []),
],
),
],
)
}
pub fn render_disabled_operation_checkbox(
operation: constants.Flag,
current_disabled_operations: Int,
) {
let is_checked =
int.bitwise_and(current_disabled_operations, operation.value)
== operation.value
ui.custom_checkbox(
"disabled_operations[]",
int.to_string(operation.value),
operation.name,
is_checked,
option.Some(
"document.getElementById('disabled-ops-save-button').classList.remove('hidden')",
),
)
}

View File

@@ -0,0 +1,535 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/assets
import fluxer_admin/api/guilds
import fluxer_admin/api/search
import fluxer_admin/components/flash
import fluxer_admin/web.{type Context, type Session}
import gleam/int
import gleam/list
import gleam/option
import gleam/result
import gleam/string
import wisp.{type Request, type Response}
pub fn handle_clear_fields(
req: Request,
ctx: Context,
session: Session,
guild_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let fields = case list.key_find(form_data.values, "fields") {
Ok(value) -> [value]
Error(_) -> []
}
case guilds.clear_guild_fields(ctx, session, guild_id, fields) {
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"Guild fields cleared successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to clear guild fields",
)
}
}
pub fn handle_update_features(
req: Request,
ctx: Context,
session: Session,
guild_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let guild_result = guilds.lookup_guild(ctx, session, guild_id)
case guild_result {
Error(_) -> flash.redirect_with_error(ctx, redirect_url, "Guild not found")
Ok(option.None) ->
flash.redirect_with_error(ctx, redirect_url, "Guild not found")
Ok(option.Some(current_guild)) -> {
let submitted_features =
list.filter_map(form_data.values, fn(field) {
case field.0 {
"features[]" -> Ok(field.1)
_ -> Error(Nil)
}
})
let custom_features_input =
list.key_find(form_data.values, "custom_features")
|> result.unwrap("")
let custom_features =
string.split(custom_features_input, ",")
|> list.map(string.trim)
|> list.filter(fn(s) { s != "" })
let submitted_features = list.append(submitted_features, custom_features)
let submitted_features = case
list.contains(submitted_features, "UNAVAILABLE_FOR_EVERYONE")
&& list.contains(
submitted_features,
"UNAVAILABLE_FOR_EVERYONE_BUT_STAFF",
)
{
True ->
list.filter(submitted_features, fn(f) {
f != "UNAVAILABLE_FOR_EVERYONE_BUT_STAFF"
})
False -> submitted_features
}
let add_features =
list.filter(submitted_features, fn(feature) {
!list.contains(current_guild.features, feature)
})
let remove_features =
list.filter(current_guild.features, fn(feature) {
!list.contains(submitted_features, feature)
})
case
guilds.update_guild_features(
ctx,
session,
guild_id,
add_features,
remove_features,
)
{
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"Guild features updated successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to update guild features",
)
}
}
}
}
pub fn handle_update_disabled_operations(
req: Request,
ctx: Context,
session: Session,
guild_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let checked_ops =
list.filter_map(form_data.values, fn(field) {
case field.0 {
"disabled_operations[]" -> Ok(field.1)
_ -> Error(Nil)
}
})
let disabled_ops_value =
list.fold(checked_ops, 0, fn(acc, op_str) {
case int.parse(op_str) {
Ok(val) -> int.bitwise_or(acc, val)
Error(_) -> acc
}
})
case
guilds.update_guild_settings(
ctx,
session,
guild_id,
option.None,
option.None,
option.None,
option.None,
option.None,
option.Some(disabled_ops_value),
)
{
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"Disabled operations updated successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to update disabled operations",
)
}
}
pub fn handle_update_name(
req: Request,
ctx: Context,
session: Session,
guild_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let name = list.key_find(form_data.values, "name") |> result.unwrap("")
case guilds.update_guild_name(ctx, session, guild_id, name) {
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"Guild name updated successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to update guild name",
)
}
}
pub fn handle_update_vanity(
req: Request,
ctx: Context,
session: Session,
guild_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let vanity = case list.key_find(form_data.values, "vanity_url_code") {
Ok("") -> option.None
Ok(code) -> option.Some(code)
Error(_) -> option.None
}
case guilds.update_guild_vanity(ctx, session, guild_id, vanity) {
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"Vanity URL updated successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to update vanity URL",
)
}
}
pub fn handle_transfer_ownership(
req: Request,
ctx: Context,
session: Session,
guild_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let new_owner_id =
list.key_find(form_data.values, "new_owner_id") |> result.unwrap("")
case guilds.transfer_guild_ownership(ctx, session, guild_id, new_owner_id) {
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"Guild ownership transferred successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to transfer guild ownership",
)
}
}
pub fn handle_reload(
ctx: Context,
session: Session,
guild_id: String,
redirect_url: String,
) -> Response {
case guilds.reload_guild(ctx, session, guild_id) {
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"Guild reloaded successfully",
)
Error(_) ->
flash.redirect_with_error(ctx, redirect_url, "Failed to reload guild")
}
}
pub fn handle_shutdown(
ctx: Context,
session: Session,
guild_id: String,
redirect_url: String,
) -> Response {
case guilds.shutdown_guild(ctx, session, guild_id) {
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"Guild shutdown successfully",
)
Error(_) ->
flash.redirect_with_error(ctx, redirect_url, "Failed to shutdown guild")
}
}
pub fn handle_delete_guild(
ctx: Context,
session: Session,
guild_id: String,
redirect_url: String,
) -> Response {
case guilds.delete_guild(ctx, session, guild_id) {
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"Guild deleted successfully",
)
Error(_) ->
flash.redirect_with_error(ctx, redirect_url, "Failed to delete guild")
}
}
pub fn handle_update_settings(
req: Request,
ctx: Context,
session: Session,
guild_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let verification_level =
list.key_find(form_data.values, "verification_level")
|> result.try(int.parse)
|> option.from_result
let mfa_level =
list.key_find(form_data.values, "mfa_level")
|> result.try(int.parse)
|> option.from_result
let nsfw_level =
list.key_find(form_data.values, "nsfw_level")
|> result.try(int.parse)
|> option.from_result
let explicit_content_filter =
list.key_find(form_data.values, "explicit_content_filter")
|> result.try(int.parse)
|> option.from_result
let default_message_notifications =
list.key_find(form_data.values, "default_message_notifications")
|> result.try(int.parse)
|> option.from_result
case
guilds.update_guild_settings(
ctx,
session,
guild_id,
verification_level,
mfa_level,
nsfw_level,
explicit_content_filter,
default_message_notifications,
option.None,
)
{
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"Guild settings updated successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to update guild settings",
)
}
}
pub fn handle_force_add_user(
req: Request,
ctx: Context,
session: Session,
guild_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let user_id = list.key_find(form_data.values, "user_id") |> result.unwrap("")
case guilds.force_add_user_to_guild(ctx, session, user_id, guild_id) {
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"User added to guild successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to add user to guild",
)
}
}
pub fn handle_refresh_search_index(
req: Request,
ctx: Context,
session: Session,
guild_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let index_type =
list.key_find(form_data.values, "index_type") |> result.unwrap("")
case
search.refresh_search_index_with_guild(
ctx,
session,
index_type,
option.Some(guild_id),
option.None,
)
{
Ok(response) ->
flash.redirect_with_success(
ctx,
"/search-index?job_id=" <> response.job_id,
"Search index refresh started successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to start search index refresh",
)
}
}
pub fn handle_delete_emoji(
req: Request,
ctx: Context,
session: Session,
_guild_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let emoji_id =
list.key_find(form_data.values, "emoji_id")
|> result.unwrap("")
|> string.trim
case emoji_id {
"" -> flash.redirect_with_error(ctx, redirect_url, "Emoji ID is required.")
_ -> handle_delete_asset(ctx, session, redirect_url, emoji_id, "Emoji")
}
}
pub fn handle_delete_sticker(
req: Request,
ctx: Context,
session: Session,
_guild_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let sticker_id =
list.key_find(form_data.values, "sticker_id")
|> result.unwrap("")
|> string.trim
case sticker_id {
"" ->
flash.redirect_with_error(ctx, redirect_url, "Sticker ID is required.")
_ -> handle_delete_asset(ctx, session, redirect_url, sticker_id, "Sticker")
}
}
fn handle_delete_asset(
ctx: Context,
session: Session,
redirect_url: String,
asset_id: String,
asset_label: String,
) -> Response {
case assets.purge_assets(ctx, session, [asset_id], option.None) {
Ok(response) -> {
case list.find(response.errors, fn(err) { err.id == asset_id }) {
Ok(err) ->
flash.redirect_with_error(
ctx,
redirect_url,
asset_label <> " deletion failed: " <> err.error,
)
Error(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
asset_label <> " deleted successfully.",
)
}
}
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
asset_label <> " deletion failed.",
)
}
}

View File

@@ -0,0 +1,159 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/guild_assets
import fluxer_admin/components/errors
import fluxer_admin/components/ui
import fluxer_admin/constants
import fluxer_admin/web.{type Context, type Session, action, href}
import gleam/int
import gleam/list
import gleam/option
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
pub fn emojis_tab(
ctx: Context,
session: Session,
guild_id: String,
admin_acls: List(String),
) {
let has_permission = acl.has_permission(admin_acls, constants.acl_asset_purge)
case has_permission {
True ->
case guild_assets.list_guild_emojis(ctx, session, guild_id) {
Ok(response) -> render_emojis(ctx, guild_id, response.emojis)
Error(err) ->
errors.api_error_view(
ctx,
err,
option.Some("/guilds/" <> guild_id <> "?tab=emojis"),
option.Some("Back to Guild"),
)
}
False -> render_permission_notice()
}
}
fn render_emojis(
ctx: Context,
guild_id: String,
emojis: List(guild_assets.GuildEmojiAsset),
) {
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin(
"Emojis (" <> int.to_string(list.length(emojis)) <> ")",
),
case list.is_empty(emojis) {
True ->
h.p([a.class("text-sm text-neutral-600")], [
element.text("No custom emojis found for this guild."),
])
False ->
h.div(
[a.class("mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3")],
list.map(emojis, fn(emoji) { render_emoji_card(ctx, guild_id, emoji) }),
)
},
])
}
fn render_emoji_card(
ctx: Context,
guild_id: String,
emoji: guild_assets.GuildEmojiAsset,
) {
h.div(
[
a.class(
"flex flex-col border border-neutral-200 rounded-lg overflow-hidden bg-white shadow-sm",
),
],
[
h.div(
[a.class("bg-neutral-100 flex items-center justify-center p-6 h-32")],
[
h.img([
a.src(emoji.media_url),
a.alt(emoji.name),
a.class("max-h-full max-w-full object-contain"),
a.loading("lazy"),
]),
],
),
h.div([a.class("px-4 py-3 flex-1 flex flex-col")], [
h.div([a.class("flex items-center justify-between gap-2")], [
h.span([a.class("text-sm font-semibold text-neutral-900")], [
element.text(emoji.name),
]),
case emoji.animated {
True ->
h.span(
[
a.class(
"text-xs font-semibold uppercase tracking-wide text-neutral-500 px-2 py-0.5 border border-neutral-200 rounded",
),
],
[element.text("Animated")],
)
False -> element.none()
},
]),
h.p([a.class("text-xs text-neutral-500 mt-1 break-words")], [
element.text("ID: " <> emoji.id),
]),
h.a(
[
href(ctx, "/users/" <> emoji.creator_id),
a.class("text-xs text-blue-600 hover:underline mt-1"),
],
[
element.text("Uploader: " <> emoji.creator_id),
],
),
h.form(
[
action(
ctx,
"/guilds/" <> guild_id <> "?tab=emojis&action=delete-emoji",
),
a.method("post"),
a.class("mt-4"),
],
[
h.input([a.type_("hidden"), a.name("emoji_id"), a.value(emoji.id)]),
ui.button("Delete Emoji", "submit", ui.Danger, ui.Small, ui.Full, [
a.class("mt-2"),
]),
],
),
]),
],
)
}
fn render_permission_notice() {
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Permission required"),
h.p([a.class("text-sm text-neutral-600")], [
element.text("You need the asset:purge ACL to manage guild emojis."),
]),
])
}

View File

@@ -0,0 +1,71 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/guilds
import fluxer_admin/components/ui
import fluxer_admin/pages/guild_detail/forms
import fluxer_admin/web.{type Context}
import gleam/list
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
pub fn features_tab(
ctx: Context,
guild: guilds.GuildLookupResult,
guild_id: String,
admin_acls: List(String),
) {
h.div([a.class("space-y-6")], [
case acl.has_permission(admin_acls, "guild:update:features") {
True ->
ui.card(ui.PaddingMedium, [
h.h2([a.class("text-base font-medium text-neutral-900 mb-4")], [
element.text("Guild Features"),
]),
h.p([a.class("text-sm text-neutral-600 mb-4")], [
element.text("Select which features are enabled for this guild."),
]),
forms.render_features_form(ctx, guild.features, guild_id),
])
False ->
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Guild Features"),
case list.is_empty(guild.features) {
True ->
h.p([a.class("text-sm text-neutral-600")], [
element.text("No features enabled"),
])
False ->
h.div([a.class("flex flex-wrap gap-2")], {
list.map(guild.features, fn(feature) {
h.span(
[
a.class(
"px-3 py-1 bg-purple-100 text-purple-700 text-sm rounded",
),
],
[element.text(feature)],
)
})
})
},
])
},
])
}

View File

@@ -0,0 +1,331 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/guilds_members
import fluxer_admin/avatar
import fluxer_admin/badge
import fluxer_admin/components/ui
import fluxer_admin/user
import fluxer_admin/web.{type Context, type Session, href}
import gleam/int
import gleam/list
import gleam/option
import gleam/string
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
pub fn members_tab(
ctx: Context,
session: Session,
guild_id: String,
admin_acls: List(String),
page: Int,
) {
let limit = 50
let offset = page * limit
case acl.has_permission(admin_acls, "guild:list:members") {
True -> {
case
guilds_members.list_guild_members(ctx, session, guild_id, limit, offset)
{
Ok(response) ->
render_members_list(ctx, guild_id, response, page, limit)
Error(common.Forbidden(message)) ->
render_error("Permission Denied", message)
Error(common.NotFound) -> render_error("Not Found", "Guild not found.")
Error(_) ->
render_error(
"Error",
"Failed to load guild members. Please try again.",
)
}
}
False ->
render_error(
"Permission Denied",
"You don't have permission to view guild members.",
)
}
}
fn render_error(title: String, message: String) {
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin(title),
h.p([a.class("text-sm text-neutral-600")], [element.text(message)]),
])
}
fn render_members_list(
ctx: Context,
guild_id: String,
response: guilds_members.ListGuildMembersResponse,
page: Int,
limit: Int,
) {
h.div([a.class("space-y-6")], [
ui.card(ui.PaddingMedium, [
h.div([a.class("flex justify-between items-center mb-4")], [
ui.heading_card(
"Guild Members (" <> int.to_string(response.total) <> ")",
),
render_pagination_info(response.offset, response.limit, response.total),
]),
case list.is_empty(response.members) {
True ->
h.p([a.class("text-sm text-neutral-600")], [
element.text("No members found."),
])
False ->
h.div([a.class("space-y-2")], {
list.map(response.members, render_member(ctx, _))
})
},
render_pagination(ctx, guild_id, page, response.total, limit),
]),
])
}
fn render_pagination_info(offset: Int, limit: Int, total: Int) {
let start = offset + 1
let end = case offset + limit > total {
True -> total
False -> offset + limit
}
h.p([a.class("text-sm text-neutral-600")], [
element.text(
"Showing "
<> int.to_string(start)
<> "-"
<> int.to_string(end)
<> " of "
<> int.to_string(total),
),
])
}
fn render_pagination(
ctx: Context,
guild_id: String,
current_page: Int,
total: Int,
limit: Int,
) {
let total_pages = { total + limit - 1 } / limit
let has_previous = current_page > 0
let has_next = current_page < total_pages - 1
case total_pages > 1 {
False -> element.none()
True ->
h.div([a.class("flex justify-between items-center mt-4 pt-4 border-t")], [
case has_previous {
True ->
h.a(
[
href(
ctx,
"/guilds/"
<> guild_id
<> "?tab=members&page="
<> int.to_string(current_page - 1),
),
a.class(
"px-4 py-2 bg-neutral-900 text-white rounded text-sm font-medium hover:bg-neutral-800 transition-colors",
),
],
[element.text("← Previous")],
)
False ->
h.div(
[
a.class(
"px-4 py-2 bg-neutral-300 text-neutral-500 rounded text-sm font-medium cursor-not-allowed",
),
],
[element.text("← Previous")],
)
},
h.div([a.class("text-sm text-neutral-600")], [
element.text(
"Page "
<> int.to_string(current_page + 1)
<> " of "
<> int.to_string(total_pages),
),
]),
case has_next {
True ->
h.a(
[
href(
ctx,
"/guilds/"
<> guild_id
<> "?tab=members&page="
<> int.to_string(current_page + 1),
),
a.class(
"px-4 py-2 bg-neutral-900 text-white rounded text-sm font-medium hover:bg-neutral-800 transition-colors",
),
],
[element.text("Next →")],
)
False ->
h.div(
[
a.class(
"px-4 py-2 bg-neutral-300 text-neutral-500 rounded text-sm font-medium cursor-not-allowed",
),
],
[element.text("Next →")],
)
},
])
}
}
fn render_member(ctx: Context, member: guilds_members.GuildMember) {
let badges =
badge.get_user_badges(
ctx.cdn_endpoint,
int.to_string(member.user.public_flags),
)
h.div(
[
a.class(
"bg-white border border-neutral-200 rounded-lg overflow-hidden hover:border-neutral-300 transition-colors",
),
],
[
h.div([a.class("p-5")], [
h.div([a.class("flex items-center gap-4")], [
h.img([
a.src(avatar.get_user_avatar_url(
ctx.media_endpoint,
ctx.cdn_endpoint,
member.user.id,
member.user.avatar,
True,
ctx.asset_version,
)),
a.alt(member.user.username),
a.class("w-16 h-16 rounded-full flex-shrink-0"),
]),
h.div([a.class("flex-1 min-w-0")], [
h.div([a.class("flex items-center gap-2 mb-1")], [
h.h2([a.class("text-base font-medium text-neutral-900")], [
element.text(
member.user.username
<> "#"
<> case int.parse(member.user.discriminator) {
Ok(disc_int) -> user.format_discriminator(disc_int)
Error(_) -> member.user.discriminator
},
),
]),
case member.user.bot {
True ->
h.span(
[
a.class("px-2 py-0.5 bg-blue-100 text-blue-700 rounded"),
],
[element.text("Bot")],
)
False -> element.none()
},
case member.nick {
option.Some(nick) ->
h.span(
[
a.class("text-sm text-neutral-600 ml-2"),
],
[
element.text("(" <> nick <> ")"),
],
)
option.None -> element.none()
},
]),
case list.is_empty(badges) {
False ->
h.div(
[a.class("flex items-center gap-1.5 mb-2")],
list.map(badges, fn(b) {
h.img([
a.src(b.icon),
a.alt(b.name),
a.title(b.name),
a.class("w-5 h-5"),
])
}),
)
True -> element.none()
},
h.div([a.class("space-y-0.5")], [
h.div([a.class("text-sm text-neutral-600")], [
element.text("ID: " <> member.user.id),
]),
case user.extract_timestamp(member.user.id) {
Ok(created_at) ->
h.div([a.class("text-sm text-neutral-500")], [
element.text("Created: " <> created_at),
])
Error(_) -> element.none()
},
h.div([a.class("text-sm text-neutral-500")], [
element.text("Joined: " <> format_date(member.joined_at)),
]),
case member.roles != [] {
True ->
h.div([a.class("text-sm text-neutral-500")], [
element.text(
int.to_string(list.length(member.roles)) <> " roles",
),
])
False -> element.none()
},
]),
]),
h.a(
[
href(ctx, "/users/" <> member.user.id),
a.class(
"px-4 py-2 bg-neutral-900 text-white rounded-lg text-sm font-medium hover:bg-neutral-800 transition-colors flex-shrink-0 no-underline",
),
],
[element.text("View Details")],
),
]),
]),
],
)
}
fn format_date(iso_date: String) -> String {
case iso_date {
_ ->
case string.split(iso_date, "T") {
[date, ..] -> date
_ -> iso_date
}
}
}

View File

@@ -0,0 +1,250 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/guilds
import fluxer_admin/components/ui
import fluxer_admin/constants
import fluxer_admin/web.{type Context, action}
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
pub fn moderation_tab(
ctx: Context,
_guild: guilds.GuildLookupResult,
guild_id: String,
admin_acls: List(String),
) {
h.div([a.class("space-y-6")], [
case acl.has_permission(admin_acls, "guild:update:name") {
True ->
ui.card(ui.PaddingMedium, [
h.h2([a.class("text-base font-medium text-neutral-900 mb-4")], [
element.text("Update Guild Name"),
]),
h.form(
[
a.method("POST"),
action(
ctx,
"/guilds/" <> guild_id <> "?action=update-name&tab=moderation",
),
a.attribute(
"onsubmit",
"return confirm('Are you sure you want to change this guild\\'s name?')",
),
],
[
h.input([
a.type_("text"),
a.name("name"),
a.placeholder("New guild name"),
a.required(True),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm mb-3",
),
]),
ui.button_primary("Update Name", "submit", []),
],
),
])
False -> element.none()
},
case acl.has_permission(admin_acls, "guild:update:vanity") {
True ->
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Update Vanity URL"),
h.form(
[
a.method("POST"),
action(
ctx,
"/guilds/" <> guild_id <> "?action=update-vanity&tab=moderation",
),
a.attribute(
"onsubmit",
"return confirm('Are you sure you want to change this guild\\'s vanity URL?')",
),
],
[
h.input([
a.type_("text"),
a.name("vanity_url_code"),
a.placeholder("vanity-code (leave empty to remove)"),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm mb-3",
),
]),
ui.button_primary("Update Vanity URL", "submit", []),
],
),
])
False -> element.none()
},
case acl.has_permission(admin_acls, "guild:transfer_ownership") {
True ->
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Transfer Ownership"),
h.form(
[
a.method("POST"),
action(
ctx,
"/guilds/"
<> guild_id
<> "?action=transfer-ownership&tab=moderation",
),
a.attribute(
"onsubmit",
"return confirm('Are you sure you want to transfer ownership of this guild? This action cannot be easily undone.')",
),
],
[
h.input([
a.type_("text"),
a.name("new_owner_id"),
a.placeholder("New owner user ID"),
a.required(True),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm mb-3",
),
]),
ui.button_danger("Transfer Ownership", "submit", []),
],
),
])
False -> element.none()
},
case acl.has_permission(admin_acls, "guild:force_add_user") {
True ->
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Force Add User to Guild"),
h.form(
[
a.method("POST"),
action(
ctx,
"/guilds/"
<> guild_id
<> "?action=force-add-user&tab=moderation",
),
a.attribute(
"onsubmit",
"return confirm('Are you sure you want to force add this user to the guild?')",
),
],
[
h.input([
a.type_("text"),
a.name("user_id"),
a.placeholder("User ID to add"),
a.required(True),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm mb-3",
),
]),
ui.button_primary("Add User", "submit", []),
],
),
])
False -> element.none()
},
case
acl.has_permission(admin_acls, "guild:reload")
|| acl.has_permission(admin_acls, "guild:shutdown")
{
True ->
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Guild Process Controls"),
h.div([a.class("flex flex-wrap gap-3")], [
case acl.has_permission(admin_acls, "guild:reload") {
True ->
h.form(
[
a.method("POST"),
action(
ctx,
"/guilds/" <> guild_id <> "?action=reload&tab=moderation",
),
a.attribute(
"onsubmit",
"return confirm('Are you sure you want to reload this guild process?')",
),
],
[
ui.button_success("Reload Guild", "submit", []),
],
)
False -> element.none()
},
case acl.has_permission(admin_acls, "guild:shutdown") {
True ->
h.form(
[
a.method("POST"),
action(
ctx,
"/guilds/"
<> guild_id
<> "?action=shutdown&tab=moderation",
),
a.attribute(
"onsubmit",
"return confirm('Are you sure you want to shutdown this guild process?')",
),
],
[
ui.button_danger("Shutdown Guild", "submit", []),
],
)
False -> element.none()
},
]),
])
False -> element.none()
},
case acl.has_permission(admin_acls, constants.acl_guild_delete) {
True ->
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Delete Guild"),
h.p([a.class("text-sm text-neutral-600 mb-4")], [
element.text(
"Deleting a guild permanently removes it and all associated data. This action cannot be undone.",
),
]),
h.form(
[
a.method("POST"),
action(
ctx,
"/guilds/" <> guild_id <> "?action=delete-guild&tab=moderation",
),
a.attribute(
"onsubmit",
"return confirm('Are you sure you want to permanently delete this guild? This action cannot be undone.')",
),
],
[
ui.button_danger("Delete Guild", "submit", []),
],
),
])
False -> element.none()
},
])
}

View File

@@ -0,0 +1,335 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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 birl
import fluxer_admin/api/guilds
import fluxer_admin/components/ui
import fluxer_admin/web.{type Context, action, href}
import gleam/int
import gleam/list
import gleam/option
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
const fluxer_epoch = 1_420_070_400_000
fn get_current_snowflake() -> String {
let now = birl.now() |> birl.to_unix_milli
let timestamp_offset = now - fluxer_epoch
let snowflake = timestamp_offset * 4_194_304
int.to_string(snowflake)
}
pub fn overview_tab(ctx: Context, guild: guilds.GuildLookupResult) {
h.div([a.class("space-y-6")], [
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Guild Information"),
info_grid(ctx, guild, [
#("Guild ID", guild.id),
#("Name", guild.name),
#("Member Count", int.to_string(guild.member_count)),
#("Vanity URL", case guild.vanity_url_code {
option.Some(vanity) -> vanity
option.None -> "None"
}),
]),
]),
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Features"),
case list.is_empty(guild.features) {
True ->
h.p([a.class("text-sm text-neutral-600")], [
element.text("No features enabled"),
])
False ->
h.div([a.class("flex flex-wrap gap-2")], {
list.map(guild.features, fn(feature) {
h.span(
[
a.class(
"px-3 py-1 bg-purple-100 text-purple-700 text-sm rounded",
),
],
[element.text(feature)],
)
})
})
},
]),
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin(
"Channels (" <> int.to_string(list.length(guild.channels)) <> ")",
),
case list.is_empty(guild.channels) {
True ->
h.p([a.class("text-sm text-neutral-600")], [
element.text("No channels"),
])
False ->
h.div([a.class("space-y-2")], {
list.map(
list.sort(guild.channels, fn(a, b) {
int.compare(a.position, b.position)
}),
fn(channel) { render_channel(ctx, channel) },
)
})
},
]),
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin(
"Roles (" <> int.to_string(list.length(guild.roles)) <> ")",
),
case list.is_empty(guild.roles) {
True ->
h.p([a.class("text-sm text-neutral-600")], [element.text("No roles")])
False ->
h.div([a.class("space-y-2")], {
list.map(
list.sort(guild.roles, fn(a, b) {
int.compare(b.position, a.position)
}),
render_role,
)
})
},
]),
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Search Index Management"),
h.p([a.class("text-sm text-neutral-600 mb-4")], [
element.text("Refresh search indexes for this guild."),
]),
h.div([a.class("space-y-3")], [
render_search_index_button(
ctx,
guild.id,
"Channel Messages",
"channel_messages",
),
]),
]),
])
}
fn info_grid(
ctx: Context,
guild: guilds.GuildLookupResult,
items: List(#(String, String)),
) {
let owner_item =
ui.info_item(
"Owner ID",
h.a(
[
href(ctx, "/users/" <> guild.owner_id),
a.class(
"text-sm text-neutral-900 hover:text-blue-600 hover:underline",
),
],
[element.text(guild.owner_id)],
),
)
let other_items =
list.map(items, fn(item) {
let #(label, value) = item
ui.info_item_text(label, value)
})
ui.info_grid([owner_item, ..other_items])
}
fn render_channel(ctx: Context, channel: guilds.GuildChannel) {
let current_snowflake = get_current_snowflake()
h.a(
[
href(
ctx,
"/messages?channel_id="
<> channel.id
<> "&message_id="
<> current_snowflake
<> "&context_limit=50",
),
a.class(
"flex items-center gap-3 p-3 bg-neutral-50 rounded border border-neutral-200 hover:bg-neutral-100 transition-colors",
),
],
[
h.div([a.class("flex-1")], [
h.div([a.class("text-sm font-medium text-neutral-900")], [
element.text(channel.name),
]),
h.div([a.class("text-sm text-neutral-600")], [
element.text(channel.id),
]),
]),
h.div([a.class("text-sm text-neutral-600")], [
element.text(channel_type_to_string(channel.type_)),
]),
],
)
}
fn render_role(role: guilds.GuildRole) {
let color_hex = int_to_hex(role.color)
h.div(
[
a.class(
"flex items-center gap-3 p-3 bg-neutral-50 rounded border border-neutral-200",
),
],
[
h.div(
[
a.class("w-4 h-4 rounded"),
a.attribute("style", "background-color: #" <> color_hex),
],
[],
),
h.div([a.class("flex-1")], [
h.div([a.class("text-sm font-medium text-neutral-900")], [
element.text(role.name),
]),
h.div([a.class("text-sm text-neutral-600")], [
element.text(role.id),
]),
]),
h.div([a.class("flex gap-2")], [
case role.hoist {
True ->
h.span(
[
a.class("px-2 py-0.5 bg-blue-100 text-blue-700 text-xs rounded"),
],
[element.text("Hoisted")],
)
False -> element.none()
},
case role.mentionable {
True ->
h.span(
[
a.class(
"px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded",
),
],
[element.text("Mentionable")],
)
False -> element.none()
},
]),
],
)
}
fn channel_type_to_string(type_: Int) -> String {
case type_ {
0 -> "Text"
2 -> "Voice"
4 -> "Category"
_ -> "Unknown (" <> int.to_string(type_) <> ")"
}
}
fn int_to_hex(i: Int) -> String {
let hex_digits = "0123456789ABCDEF"
case i {
0 -> "000000"
_ -> {
let r = i / 65_536 % 256
let g = i / 256 % 256
let b = i % 256
byte_to_hex(r, hex_digits)
<> byte_to_hex(g, hex_digits)
<> byte_to_hex(b, hex_digits)
}
}
}
fn byte_to_hex(byte: Int, _hex_digits: String) -> String {
let high = byte / 16
let low = byte % 16
let high_str = case high {
0 -> "0"
1 -> "1"
2 -> "2"
3 -> "3"
4 -> "4"
5 -> "5"
6 -> "6"
7 -> "7"
8 -> "8"
9 -> "9"
10 -> "A"
11 -> "B"
12 -> "C"
13 -> "D"
14 -> "E"
15 -> "F"
_ -> "0"
}
let low_str = case low {
0 -> "0"
1 -> "1"
2 -> "2"
3 -> "3"
4 -> "4"
5 -> "5"
6 -> "6"
7 -> "7"
8 -> "8"
9 -> "9"
10 -> "A"
11 -> "B"
12 -> "C"
13 -> "D"
14 -> "E"
15 -> "F"
_ -> "0"
}
high_str <> low_str
}
fn render_search_index_button(
ctx: Context,
guild_id: String,
title: String,
index_type: String,
) {
h.form(
[
a.class("flex"),
a.method("post"),
action(ctx, "/guilds/" <> guild_id <> "?action=refresh-search-index"),
],
[
h.input([a.type_("hidden"), a.name("index_type"), a.value(index_type)]),
h.input([a.type_("hidden"), a.name("guild_id"), a.value(guild_id)]),
h.button(
[
a.type_("submit"),
a.class(
"w-full px-4 py-3 rounded-lg border border-neutral-300 bg-white text-neutral-900 text-sm font-medium hover:bg-neutral-100 transition-colors",
),
],
[element.text("Refresh " <> title)],
),
],
)
}

View File

@@ -0,0 +1,359 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/guilds
import fluxer_admin/components/ui
import fluxer_admin/pages/guild_detail/forms
import fluxer_admin/web.{type Context, action}
import gleam/int
import gleam/list
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
pub fn settings_tab(
ctx: Context,
guild: guilds.GuildLookupResult,
guild_id: String,
admin_acls: List(String),
) {
h.div([a.class("space-y-6")], [
case acl.has_permission(admin_acls, "guild:update:settings") {
True ->
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Guild Settings"),
h.form(
[
a.method("POST"),
action(
ctx,
"/guilds/" <> guild_id <> "?action=update-settings&tab=settings",
),
],
[
h.div([a.class("grid grid-cols-1 md:grid-cols-2 gap-4")], [
h.div([], [
h.label(
[
a.class("block text-sm font-medium text-neutral-600 mb-1"),
],
[element.text("Verification Level")],
),
h.select(
[
a.name("verification_level"),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded text-sm",
),
],
[
option_element("0", "None", guild.verification_level == 0),
option_element(
"1",
"Low (verified email)",
guild.verification_level == 1,
),
option_element(
"2",
"Medium (5+ minutes)",
guild.verification_level == 2,
),
option_element(
"3",
"High (10+ minutes)",
guild.verification_level == 3,
),
option_element(
"4",
"Very High (verified phone)",
guild.verification_level == 4,
),
],
),
]),
h.div([], [
h.label(
[
a.class("block text-sm font-medium text-neutral-600 mb-1"),
],
[element.text("MFA Level")],
),
h.select(
[
a.name("mfa_level"),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded text-sm",
),
],
[
option_element("0", "None", guild.mfa_level == 0),
option_element("1", "Elevated", guild.mfa_level == 1),
],
),
]),
h.div([], [
h.label(
[
a.class("block text-sm font-medium text-neutral-600 mb-1"),
],
[element.text("NSFW Level")],
),
h.select(
[
a.name("nsfw_level"),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded text-sm",
),
],
[
option_element("0", "Default", guild.nsfw_level == 0),
option_element("1", "Explicit", guild.nsfw_level == 1),
option_element("2", "Safe", guild.nsfw_level == 2),
option_element(
"3",
"Age Restricted",
guild.nsfw_level == 3,
),
],
),
]),
h.div([], [
h.label(
[
a.class("block text-sm font-medium text-neutral-600 mb-1"),
],
[element.text("Explicit Content Filter")],
),
h.select(
[
a.name("explicit_content_filter"),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded text-sm",
),
],
[
option_element(
"0",
"Disabled",
guild.explicit_content_filter == 0,
),
option_element(
"1",
"Members without roles",
guild.explicit_content_filter == 1,
),
option_element(
"2",
"All members",
guild.explicit_content_filter == 2,
),
],
),
]),
h.div([], [
h.label(
[
a.class("block text-sm font-medium text-neutral-600 mb-1"),
],
[element.text("Default Notifications")],
),
h.select(
[
a.name("default_message_notifications"),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded text-sm",
),
],
[
option_element(
"0",
"All messages",
guild.default_message_notifications == 0,
),
option_element(
"1",
"Only mentions",
guild.default_message_notifications == 1,
),
],
),
]),
]),
h.div([a.class("mt-6 pt-6 border-t border-neutral-200")], [
ui.button_primary("Save Settings", "submit", []),
]),
],
),
])
False ->
ui.card(ui.PaddingMedium, [
h.h2([a.class("text-base font-medium text-neutral-900 mb-4")], [
element.text("Guild Settings"),
]),
info_grid([
#(
"Verification Level",
verification_level_to_string(guild.verification_level),
),
#("MFA Level", mfa_level_to_string(guild.mfa_level)),
#("NSFW Level", nsfw_level_to_string(guild.nsfw_level)),
#(
"Explicit Content Filter",
content_filter_to_string(guild.explicit_content_filter),
),
#(
"Default Notifications",
notification_level_to_string(guild.default_message_notifications),
),
#("AFK Timeout", int.to_string(guild.afk_timeout) <> " seconds"),
]),
])
},
case acl.has_permission(admin_acls, "guild:update:settings") {
True ->
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Disabled Operations"),
forms.render_disabled_operations_form(
ctx,
guild.disabled_operations,
guild_id,
),
])
False ->
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Disabled Operations"),
h.p([a.class("text-sm text-neutral-600")], [
element.text(
"Bitfield value: " <> int.to_string(guild.disabled_operations),
),
]),
])
},
case acl.has_permission(admin_acls, "guild:update:settings") {
True ->
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Clear Guild Fields"),
h.form(
[
a.method("POST"),
action(
ctx,
"/guilds/" <> guild_id <> "?action=clear-fields&tab=settings",
),
a.attribute(
"onsubmit",
"return confirm('Are you sure you want to clear these fields?')",
),
],
[
h.div([a.class("space-y-2 mb-3")], [
h.label([a.class("flex items-center gap-2")], [
h.input([
a.type_("checkbox"),
a.name("fields"),
a.value("icon"),
]),
h.span([a.class("text-sm")], [element.text("Icon")]),
]),
h.label([a.class("flex items-center gap-2")], [
h.input([
a.type_("checkbox"),
a.name("fields"),
a.value("banner"),
]),
h.span([a.class("text-sm")], [element.text("Banner")]),
]),
h.label([a.class("flex items-center gap-2")], [
h.input([
a.type_("checkbox"),
a.name("fields"),
a.value("splash"),
]),
h.span([a.class("text-sm")], [element.text("Splash")]),
]),
]),
ui.button_danger("Clear Selected Fields", "submit", []),
],
),
])
False -> element.none()
},
])
}
fn info_grid(items: List(#(String, String))) {
let info_items =
list.map(items, fn(item) {
let #(label, value) = item
ui.info_item_text(label, value)
})
ui.info_grid(info_items)
}
fn verification_level_to_string(level: Int) -> String {
case level {
0 -> "None"
1 -> "Low (verified email)"
2 -> "Medium (registered for 5 minutes)"
3 -> "High (member for 10 minutes)"
4 -> "Very High (verified phone)"
_ -> "Unknown (" <> int.to_string(level) <> ")"
}
}
fn mfa_level_to_string(level: Int) -> String {
case level {
0 -> "None"
1 -> "Elevated"
_ -> "Unknown (" <> int.to_string(level) <> ")"
}
}
fn nsfw_level_to_string(level: Int) -> String {
case level {
0 -> "Default"
1 -> "Explicit"
2 -> "Safe"
3 -> "Age Restricted"
_ -> "Unknown (" <> int.to_string(level) <> ")"
}
}
fn content_filter_to_string(level: Int) -> String {
case level {
0 -> "Disabled"
1 -> "Members without roles"
2 -> "All members"
_ -> "Unknown (" <> int.to_string(level) <> ")"
}
}
fn notification_level_to_string(level: Int) -> String {
case level {
0 -> "All messages"
1 -> "Only mentions"
_ -> "Unknown (" <> int.to_string(level) <> ")"
}
}
fn option_element(value: String, label: String, selected: Bool) {
element.element("option", [a.value(value), a.selected(selected)], [
element.text(label),
])
}

View File

@@ -0,0 +1,168 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/guild_assets
import fluxer_admin/components/errors
import fluxer_admin/components/ui
import fluxer_admin/constants
import fluxer_admin/web.{type Context, type Session, action, href}
import gleam/int
import gleam/list
import gleam/option
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
pub fn stickers_tab(
ctx: Context,
session: Session,
guild_id: String,
admin_acls: List(String),
) {
case acl.has_permission(admin_acls, constants.acl_asset_purge) {
True ->
case guild_assets.list_guild_stickers(ctx, session, guild_id) {
Ok(response) -> render_stickers(ctx, guild_id, response.stickers)
Error(err) ->
errors.api_error_view(
ctx,
err,
option.Some("/guilds/" <> guild_id <> "?tab=stickers"),
option.Some("Back to Guild"),
)
}
False -> render_permission_notice()
}
}
fn render_stickers(
ctx: Context,
guild_id: String,
stickers: List(guild_assets.GuildStickerAsset),
) {
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin(
"Stickers (" <> int.to_string(list.length(stickers)) <> ")",
),
case list.is_empty(stickers) {
True ->
h.p([a.class("text-sm text-neutral-600")], [
element.text("No stickers found for this guild."),
])
False ->
h.div(
[a.class("mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3")],
list.map(stickers, fn(sticker) {
render_sticker_card(ctx, guild_id, sticker)
}),
)
},
])
}
fn render_sticker_card(
ctx: Context,
guild_id: String,
sticker: guild_assets.GuildStickerAsset,
) {
h.div(
[
a.class(
"flex flex-col border border-neutral-200 rounded-lg overflow-hidden bg-white shadow-sm",
),
],
[
h.div(
[a.class("bg-neutral-100 flex items-center justify-center p-6 h-32")],
[
h.img([
a.src(sticker.media_url),
a.alt(sticker.name),
a.class("max-h-full max-w-full object-contain"),
a.loading("lazy"),
]),
],
),
h.div([a.class("px-4 py-3 flex-1 flex flex-col")], [
h.div([a.class("flex items-center justify-between gap-2")], [
h.span([a.class("text-sm font-semibold text-neutral-900")], [
element.text(sticker.name),
]),
h.span(
[
a.class(
"text-xs font-semibold uppercase tracking-wide text-neutral-500 px-2 py-0.5 border border-neutral-200 rounded",
),
],
[
element.text(sticker_format_label(sticker.format_type)),
],
),
]),
h.p([a.class("text-xs text-neutral-500 mt-1 break-words")], [
element.text("ID: " <> sticker.id),
]),
h.a(
[
href(ctx, "/users/" <> sticker.creator_id),
a.class("text-xs text-blue-600 hover:underline mt-1"),
],
[
element.text("Uploader: " <> sticker.creator_id),
],
),
h.form(
[
action(
ctx,
"/guilds/" <> guild_id <> "?tab=stickers&action=delete-sticker",
),
a.method("post"),
a.class("mt-4"),
],
[
h.input([
a.type_("hidden"),
a.name("sticker_id"),
a.value(sticker.id),
]),
ui.button("Delete Sticker", "submit", ui.Danger, ui.Small, ui.Full, [
a.class("mt-2"),
]),
],
),
]),
],
)
}
fn sticker_format_label(format_type: Int) -> String {
case format_type {
4 -> "GIF"
_ -> "PNG"
}
}
fn render_permission_notice() {
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Permission required"),
h.p([a.class("text-sm text-neutral-600")], [
element.text("You need the asset:purge ACL to manage guild stickers."),
]),
])
}

View File

@@ -0,0 +1,566 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/archives
import fluxer_admin/api/common
import fluxer_admin/api/guilds
import fluxer_admin/avatar
import fluxer_admin/components/date_time
import fluxer_admin/components/errors
import fluxer_admin/components/flash
import fluxer_admin/components/layout
import fluxer_admin/components/tabs
import fluxer_admin/components/ui
import fluxer_admin/constants
import fluxer_admin/pages/guild_detail/handlers
import fluxer_admin/pages/guild_detail/tabs/emojis
import fluxer_admin/pages/guild_detail/tabs/features
import fluxer_admin/pages/guild_detail/tabs/members
import fluxer_admin/pages/guild_detail/tabs/moderation
import fluxer_admin/pages/guild_detail/tabs/overview
import fluxer_admin/pages/guild_detail/tabs/settings
import fluxer_admin/pages/guild_detail/tabs/stickers
import fluxer_admin/web.{type Context, type Session, action, href, redirect}
import gleam/int
import gleam/list
import gleam/option
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Request, type Response}
pub fn view(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
guild_id: String,
referrer: option.Option(String),
tab: option.Option(String),
page: option.Option(String),
) -> Response {
let result = guilds.lookup_guild(ctx, session, guild_id)
let admin_acls = case current_admin {
option.Some(admin) -> admin.acls
_ -> []
}
let can_view_archives =
list.any(admin_acls, fn(acl) {
acl == constants.acl_archive_view_all
|| acl == constants.acl_archive_trigger_guild
|| acl == constants.acl_wildcard
})
let can_manage_assets =
acl.has_permission(admin_acls, constants.acl_asset_purge)
let active_tab = case tab {
option.Some("settings") -> "settings"
option.Some("features") -> "features"
option.Some("moderation") -> "moderation"
option.Some("members") -> "members"
option.Some("archives") -> "archives"
option.Some("emojis") -> "emojis"
option.Some("stickers") -> "stickers"
_ -> "overview"
}
let active_tab = case active_tab {
"archives" if can_view_archives == False -> "overview"
"emojis" if can_manage_assets == False -> "overview"
"stickers" if can_manage_assets == False -> "overview"
_ -> active_tab
}
let current_page = case page {
option.Some(p) ->
case int.parse(p) {
Ok(page_num) -> page_num
Error(_) -> 0
}
option.None -> 0
}
let content = case result {
Ok(option.Some(guild_data)) -> {
h.div([a.class("max-w-7xl mx-auto")], [
h.div([a.class("mb-6")], [
h.a(
[
href(ctx, option.unwrap(referrer, "/guilds")),
a.class(
"inline-flex items-center gap-2 text-neutral-600 hover:text-neutral-900 transition-colors",
),
],
[
h.span([a.class("text-lg")], [element.text("")]),
element.text("Back to Guilds"),
],
),
]),
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(
"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")], [
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(
ctx,
session,
guild_data,
admin_acls,
guild_id,
active_tab,
current_page,
),
])
}
Ok(option.None) -> not_found_view(ctx)
Error(err) ->
errors.api_error_view(
ctx,
err,
option.Some("/guilds"),
option.Some("Back to Guilds"),
)
}
let html =
layout.page(
"Guild Details",
"guilds",
ctx,
session,
current_admin,
flash_data,
content,
)
wisp.html_response(element.to_document_string(html), 200)
}
fn render_tabs(
ctx: Context,
session: Session,
guild: guilds.GuildLookupResult,
admin_acls: List(String),
guild_id: String,
active_tab: String,
current_page: Int,
) {
let tab_list = [
tabs.Tab(
label: "Overview",
path: "/guilds/" <> guild_id <> "?tab=overview",
active: active_tab == "overview",
),
tabs.Tab(
label: "Members",
path: "/guilds/" <> guild_id <> "?tab=members",
active: active_tab == "members",
),
tabs.Tab(
label: "Settings",
path: "/guilds/" <> guild_id <> "?tab=settings",
active: active_tab == "settings",
),
tabs.Tab(
label: "Features",
path: "/guilds/" <> guild_id <> "?tab=features",
active: active_tab == "features",
),
tabs.Tab(
label: "Moderation",
path: "/guilds/" <> guild_id <> "?tab=moderation",
active: active_tab == "moderation",
),
]
let can_manage_assets =
acl.has_permission(admin_acls, constants.acl_asset_purge)
let tab_list = case
list.any(admin_acls, fn(acl) {
acl == constants.acl_archive_view_all
|| acl == constants.acl_archive_trigger_guild
|| acl == constants.acl_wildcard
})
{
True ->
tab_list
|> list.append([
tabs.Tab(
label: "Archives",
path: "/guilds/" <> guild_id <> "?tab=archives",
active: active_tab == "archives",
),
])
False -> tab_list
}
let tab_list = case can_manage_assets {
True ->
tab_list
|> list.append([
tabs.Tab(
label: "Emojis",
path: "/guilds/" <> guild_id <> "?tab=emojis",
active: active_tab == "emojis",
),
tabs.Tab(
label: "Stickers",
path: "/guilds/" <> guild_id <> "?tab=stickers",
active: active_tab == "stickers",
),
])
False -> tab_list
}
h.div([], [
tabs.render_tabs(ctx, tab_list),
case active_tab {
"members" ->
members.members_tab(ctx, session, guild_id, admin_acls, current_page)
"settings" -> settings.settings_tab(ctx, guild, guild_id, admin_acls)
"features" -> features.features_tab(ctx, guild, guild_id, admin_acls)
"moderation" ->
moderation.moderation_tab(ctx, guild, guild_id, admin_acls)
"archives" -> archives_tab(ctx, session, guild_id)
"emojis" -> emojis.emojis_tab(ctx, session, guild_id, admin_acls)
"stickers" -> stickers.stickers_tab(ctx, session, guild_id, admin_acls)
_ -> overview.overview_tab(ctx, guild)
},
])
}
fn not_found_view(ctx: Context) {
h.div([a.class("max-w-4xl mx-auto")], [
h.div(
[
a.class(
"bg-white border border-neutral-200 rounded-lg p-12 text-center",
),
],
[
h.h2([a.class("text-base font-semibold text-neutral-900 mb-2")], [
element.text("Guild Not Found"),
]),
h.p([a.class("text-neutral-600 mb-6")], [
element.text("The requested guild could not be found."),
]),
h.a(
[
href(ctx, "/guilds"),
a.class(
"inline-flex items-center gap-2 px-4 py-2 bg-neutral-900 text-white rounded-lg text-sm font-medium hover:bg-neutral-800 transition-colors",
),
],
[
h.span([a.class("text-lg")], [element.text("")]),
element.text("Back to Guilds"),
],
),
],
),
])
}
pub fn handle_action(
req: Request,
ctx: Context,
session: Session,
guild_id: String,
action: option.Option(String),
tab: option.Option(String),
) -> Response {
let redirect_url = case tab {
option.Some(t) -> "/guilds/" <> guild_id <> "?tab=" <> t
option.None -> "/guilds/" <> guild_id
}
case action {
option.Some("clear-fields") ->
handlers.handle_clear_fields(req, ctx, session, guild_id, redirect_url)
option.Some("update-features") ->
handlers.handle_update_features(req, ctx, session, guild_id, redirect_url)
option.Some("update-disabled-operations") ->
handlers.handle_update_disabled_operations(
req,
ctx,
session,
guild_id,
redirect_url,
)
option.Some("update-name") ->
handlers.handle_update_name(req, ctx, session, guild_id, redirect_url)
option.Some("update-vanity") ->
handlers.handle_update_vanity(req, ctx, session, guild_id, redirect_url)
option.Some("transfer-ownership") ->
handlers.handle_transfer_ownership(
req,
ctx,
session,
guild_id,
redirect_url,
)
option.Some("reload") ->
handlers.handle_reload(ctx, session, guild_id, redirect_url)
option.Some("shutdown") ->
handlers.handle_shutdown(ctx, session, guild_id, redirect_url)
option.Some("delete-guild") ->
handlers.handle_delete_guild(ctx, session, guild_id, "/guilds")
option.Some("update-settings") ->
handlers.handle_update_settings(req, ctx, session, guild_id, redirect_url)
option.Some("force-add-user") ->
handlers.handle_force_add_user(req, ctx, session, guild_id, redirect_url)
option.Some("refresh-search-index") ->
handlers.handle_refresh_search_index(
req,
ctx,
session,
guild_id,
redirect_url,
)
option.Some("trigger-archive") ->
handle_trigger_archive(ctx, session, guild_id, redirect_url)
option.Some("delete-emoji") ->
handlers.handle_delete_emoji(req, ctx, session, guild_id, redirect_url)
option.Some("delete-sticker") ->
handlers.handle_delete_sticker(req, ctx, session, guild_id, redirect_url)
_ -> redirect(ctx, redirect_url)
}
}
fn archives_tab(ctx: Context, session: Session, guild_id: String) {
let result =
archives.list_archives(ctx, session, "guild", option.Some(guild_id), False)
h.div([], [
ui.flex_row_between([
ui.heading_section("Guild Archives"),
h.form(
[
a.method("post"),
action(
ctx,
"/guilds/" <> guild_id <> "?tab=archives&action=trigger-archive",
),
],
[
ui.button_primary("Trigger Archive", "submit", []),
],
),
]),
case result {
Ok(response) -> render_archive_table(ctx, response.archives)
Error(err) -> errors.api_error_view(ctx, err, option.None, option.None)
},
])
}
fn render_archive_table(ctx: Context, archives: List(archives.Archive)) {
case list.is_empty(archives) {
True ->
h.div(
[
a.class(
"mt-4 p-4 border border-dashed border-neutral-300 rounded-lg text-neutral-600",
),
],
[
element.text("No archives yet for this guild."),
],
)
False ->
h.div(
[
a.class(
"mt-4 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([], [
h.th(
[
a.class(
"px-4 py-2 text-left text-xs font-medium text-neutral-700 uppercase tracking-wider",
),
],
[
element.text("Requested At"),
],
),
h.th(
[
a.class(
"px-4 py-2 text-left text-xs font-medium text-neutral-700 uppercase tracking-wider",
),
],
[
element.text("Status"),
],
),
h.th(
[
a.class(
"px-4 py-2 text-left text-xs font-medium text-neutral-700 uppercase tracking-wider",
),
],
[
element.text("Actions"),
],
),
]),
]),
h.tbody(
[a.class("divide-y divide-neutral-200")],
list.map(archives, fn(archive) {
h.tr([], [
h.td([a.class("px-4 py-3 text-sm text-neutral-900")], [
element.text(date_time.format_timestamp(
archive.requested_at,
)),
]),
h.td([a.class("px-4 py-3 text-sm text-neutral-900")], [
element.text(
status_text(archive)
<> " ("
<> int.to_string(archive.progress_percent)
<> "%)",
),
]),
h.td([a.class("px-4 py-3 text-sm")], [
case archive.completed_at {
option.Some(_) ->
h.a(
[
href(
ctx,
"/archives/download?subject_type=guild&subject_id="
<> archive.subject_id
<> "&archive_id="
<> archive.archive_id,
),
a.class(
"text-sm text-white bg-neutral-900 hover:bg-neutral-800 px-3 py-1.5 rounded transition-colors",
),
],
[element.text("Download")],
)
option.None ->
h.span([a.class("text-neutral-500")], [
element.text("Pending"),
])
},
]),
])
}),
),
]),
],
)
}
}
fn status_text(archive: archives.Archive) -> String {
case archive.failed_at {
option.Some(_) -> "Failed"
option.None -> {
case archive.completed_at {
option.Some(_) -> "Completed"
option.None -> option.unwrap(archive.progress_step, "In Progress")
}
}
}
}
fn handle_trigger_archive(
ctx: Context,
session: Session,
guild_id: String,
redirect_url: String,
) -> Response {
case archives.trigger_guild_archive(ctx, session, guild_id, option.None) {
Ok(_) -> redirect(ctx, redirect_url)
Error(err) ->
errors.api_error_view(
ctx,
err,
option.Some(redirect_url),
option.Some("Back"),
)
|> element.to_document_string
|> wisp.html_response(400)
}
}

View File

@@ -0,0 +1,266 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common
import fluxer_admin/api/guilds
import fluxer_admin/avatar
import fluxer_admin/components/errors
import fluxer_admin/components/flash
import fluxer_admin/components/layout
import fluxer_admin/components/pagination
import fluxer_admin/components/ui
import fluxer_admin/components/url_builder
import fluxer_admin/web.{type Context, type Session, href}
import gleam/int
import gleam/list
import gleam/option
import gleam/string
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Response}
pub fn view(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
query: option.Option(String),
page: Int,
) -> Response {
let limit = 50
let offset = page * limit
let result = case query {
option.Some(q) ->
case string.trim(q) {
"" -> Ok(guilds.SearchGuildsResponse(guilds: [], total: 0))
trimmed_query ->
guilds.search_guilds(ctx, session, trimmed_query, limit, offset)
}
option.None -> Ok(guilds.SearchGuildsResponse(guilds: [], total: 0))
}
let content = case result {
Ok(response) -> {
h.div([a.class("max-w-7xl mx-auto space-y-6")], [
ui.flex_row_between([
ui.heading_page("Guilds"),
case query {
option.Some(_) ->
h.div([a.class("flex items-center gap-4")], [
h.span([a.class("text-sm text-neutral-600")], [
element.text(
"Found "
<> int.to_string(response.total)
<> " results (showing "
<> int.to_string(list.length(response.guilds))
<> ")",
),
]),
])
option.None -> element.none()
},
]),
render_search_form(ctx, query),
case query {
option.Some(_) ->
case list.is_empty(response.guilds) {
True -> empty_search_results()
False ->
h.div([], [
render_guilds_grid(ctx, response.guilds),
pagination.pagination(ctx, response.total, limit, page, fn(p) {
build_pagination_url(p, query)
}),
])
}
option.None -> empty_state()
},
])
}
Error(err) -> errors.api_error_view(ctx, err, option.None, option.None)
}
let html =
layout.page(
"Guilds",
"guilds",
ctx,
session,
current_admin,
flash_data,
content,
)
wisp.html_response(element.to_document_string(html), 200)
}
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.input([
a.type_("text"),
a.name("q"),
a.value(option.unwrap(query, "")),
a.placeholder("Search by ID, guild name, or vanity URL..."),
a.class(
"flex-1 px-4 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:border-transparent",
),
a.attribute("autocomplete", "off"),
]),
ui.button_primary("Search", "submit", []),
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",
),
],
[element.text("Clear")],
),
]),
h.p([a.class("text-xs text-neutral-500")], [
element.text(
"Search supports: Guild ID, Guild Name, Vanity URL, and more",
),
]),
]),
])
}
fn render_guilds_grid(ctx: Context, guilds: List(guilds.GuildSearchResult)) {
h.div(
[a.class("grid grid-cols-1 gap-4")],
list.map(guilds, fn(guild) { render_guild_card(ctx, guild) }),
)
}
fn render_guild_card(ctx: Context, guild: guilds.GuildSearchResult) {
h.div(
[
a.class(
"bg-white border border-neutral-200 rounded-lg overflow-hidden hover:border-neutral-300 transition-colors",
),
],
[
h.div([a.class("p-5")], [
h.div([a.class("flex items-center gap-4")], [
case
avatar.get_guild_icon_url(
ctx.media_endpoint,
guild.id,
guild.icon,
True,
)
{
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"),
]),
])
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-1 min-w-0")], [
h.div([a.class("flex items-center gap-2 mb-2")], [
h.h2([a.class("text-base font-medium text-neutral-900")], [
element.text(guild.name),
]),
case list.is_empty(guild.features) {
False ->
h.span(
[
a.class(
"px-2 py-0.5 bg-purple-100 text-purple-700 text-xs rounded uppercase",
),
],
[element.text("Featured")],
)
True -> element.none()
},
]),
h.div([a.class("space-y-0.5")], [
h.div([a.class("text-sm text-neutral-600")], [
element.text("ID: " <> guild.id),
]),
h.div([a.class("text-sm text-neutral-600")], [
element.text("Members: " <> int.to_string(guild.member_count)),
]),
h.div([a.class("text-sm text-neutral-600")], [
element.text("Owner: "),
h.a(
[
href(ctx, "/users/" <> guild.owner_id),
a.class(
"hover:text-blue-600 hover:underline transition-colors",
),
],
[element.text(guild.owner_id)],
),
]),
]),
]),
h.a(
[
href(ctx, "/guilds/" <> guild.id),
a.class(
"px-4 py-2 bg-neutral-900 text-white rounded-lg text-sm hover:bg-neutral-800 transition-colors flex-shrink-0 no-underline",
),
],
[element.text("View Details")],
),
]),
]),
],
)
}
fn build_pagination_url(page: Int, query: option.Option(String)) -> String {
url_builder.build_url("/guilds", [
#("page", option.Some(int.to_string(page))),
#("q", query),
])
}
fn empty_state() {
ui.card_empty([
ui.text_muted("Enter a search query to find guilds"),
ui.text_small_muted(
"Search by Guild ID, Guild Name, Vanity URL, or other attributes",
),
])
}
fn empty_search_results() {
ui.card_empty([
ui.text_muted("No guilds found"),
ui.text_small_muted("Try adjusting your search query"),
])
}

View File

@@ -0,0 +1,345 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common
import fluxer_admin/api/instance_config
import fluxer_admin/components/errors
import fluxer_admin/components/flash
import fluxer_admin/components/layout
import fluxer_admin/components/ui
import fluxer_admin/web.{type Context, type Session, action}
import gleam/int
import gleam/list
import gleam/option
import gleam/result
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Request, type Response}
pub fn view(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
) -> Response {
let result = instance_config.get_instance_config(ctx, session)
let content = case result {
Ok(config) ->
h.div([a.class("space-y-6")], [
ui.heading_page("Instance Configuration"),
render_status_card(config),
render_config_form(ctx, config),
])
Error(err) -> errors.error_view(err)
}
let html =
layout.page(
"Instance Configuration",
"instance-config",
ctx,
session,
current_admin,
flash_data,
content,
)
wisp.html_response(element.to_document_string(html), 200)
}
fn render_status_card(config: instance_config.InstanceConfig) {
let status_color = case config.manual_review_active_now {
True -> "bg-green-100 text-green-800 border-green-200"
False -> "bg-amber-100 text-amber-800 border-amber-200"
}
let status_text = case config.manual_review_active_now {
True -> "Manual review is currently ACTIVE"
False -> "Manual review is currently INACTIVE"
}
h.div([a.class("p-4 rounded-lg border " <> status_color)], [
h.div([a.class("flex items-center gap-2")], [
h.span([a.class("text-lg font-semibold")], [element.text(status_text)]),
]),
case config.manual_review_schedule_enabled {
True ->
h.p([a.class("mt-2 text-sm")], [
element.text(
"Schedule: "
<> int.to_string(config.manual_review_schedule_start_hour_utc)
<> ":00 UTC to "
<> int.to_string(config.manual_review_schedule_end_hour_utc)
<> ":00 UTC",
),
])
False -> element.none()
},
])
}
fn render_config_form(ctx: Context, config: instance_config.InstanceConfig) {
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Manual Review Settings"),
h.p([a.class("text-sm text-neutral-600 mb-4")], [
element.text(
"Configure whether new registrations require manual review before the account is activated.",
),
]),
h.form(
[
a.method("POST"),
action(ctx, "/instance-config?action=update"),
a.class("space-y-6"),
],
[
h.div([a.class("space-y-2")], [
h.label([a.class("flex items-center gap-3 cursor-pointer")], [
h.input([
a.type_("checkbox"),
a.name("manual_review_enabled"),
a.value("true"),
a.class("w-5 h-5 rounded border-neutral-300"),
case config.manual_review_enabled {
True -> a.checked(True)
False -> a.attribute("", "")
},
]),
h.span([a.class("text-sm font-medium text-neutral-900")], [
element.text("Enable manual review for new registrations"),
]),
]),
h.p([a.class("text-xs text-neutral-500 ml-8")], [
element.text(
"When enabled, new accounts will require approval before they can use the platform.",
),
]),
]),
h.div([a.class("border-t border-neutral-200 pt-6")], [
h.label([a.class("flex items-center gap-3 cursor-pointer mb-4")], [
h.input([
a.type_("checkbox"),
a.name("schedule_enabled"),
a.value("true"),
a.class("w-5 h-5 rounded border-neutral-300"),
case config.manual_review_schedule_enabled {
True -> a.checked(True)
False -> a.attribute("", "")
},
]),
h.span([a.class("text-sm font-medium text-neutral-900")], [
element.text("Enable schedule-based activation"),
]),
]),
h.p([a.class("text-xs text-neutral-500 mb-4")], [
element.text(
"When enabled, manual review will only be active during the specified hours (UTC).",
),
]),
h.div([a.class("grid grid-cols-2 gap-4")], [
h.div([a.class("space-y-1")], [
h.label(
[
a.for("start_hour"),
a.class("text-sm font-medium text-neutral-700"),
],
[element.text("Start Hour (UTC)")],
),
h.select(
[
a.name("start_hour"),
a.id("start_hour"),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded text-sm",
),
],
list.map(list.range(0, 23), fn(hour) {
h.option(
[
a.value(int.to_string(hour)),
case
hour == config.manual_review_schedule_start_hour_utc
{
True -> a.selected(True)
False -> a.attribute("", "")
},
],
int.to_string(hour) <> ":00",
)
}),
),
]),
h.div([a.class("space-y-1")], [
h.label(
[
a.for("end_hour"),
a.class("text-sm font-medium text-neutral-700"),
],
[element.text("End Hour (UTC)")],
),
h.select(
[
a.name("end_hour"),
a.id("end_hour"),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded text-sm",
),
],
list.map(list.range(0, 23), fn(hour) {
h.option(
[
a.value(int.to_string(hour)),
case hour == config.manual_review_schedule_end_hour_utc {
True -> a.selected(True)
False -> a.attribute("", "")
},
],
int.to_string(hour) <> ":00",
)
}),
),
]),
]),
]),
h.div([a.class("border-t border-neutral-200 pt-6")], [
h.div([a.class("space-y-4")], [
h.div([a.class("space-y-1")], [
h.label(
[
a.for("registration_alerts_webhook_url"),
a.class("text-sm font-medium text-neutral-700"),
],
[element.text("Registration Alerts Webhook URL")],
),
h.input([
a.type_("url"),
a.name("registration_alerts_webhook_url"),
a.id("registration_alerts_webhook_url"),
a.value(config.registration_alerts_webhook_url),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded text-sm",
),
]),
h.p([a.class("text-xs text-neutral-500 mt-1")], [
element.text(
"Webhook URL for receiving alerts about new user registrations.",
),
]),
]),
h.div([a.class("space-y-1")], [
h.label(
[
a.for("system_alerts_webhook_url"),
a.class("text-sm font-medium text-neutral-700"),
],
[element.text("System Alerts Webhook URL")],
),
h.input([
a.type_("url"),
a.name("system_alerts_webhook_url"),
a.id("system_alerts_webhook_url"),
a.value(config.system_alerts_webhook_url),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded text-sm",
),
]),
h.p([a.class("text-xs text-neutral-500 mt-1")], [
element.text(
"Webhook URL for receiving system alerts (virus scan failures, etc.).",
),
]),
]),
]),
]),
h.div([a.class("pt-4 border-t border-neutral-200")], [
ui.button_primary("Save Configuration", "submit", []),
]),
],
),
])
}
pub fn handle_action(
req: Request,
ctx: Context,
session: Session,
action_name: String,
) -> Response {
case action_name {
"update" -> handle_update(req, ctx, session)
_ -> flash.redirect_with_error(ctx, "/instance-config", "Unknown action")
}
}
fn handle_update(req: Request, ctx: Context, session: Session) -> Response {
use form_data <- wisp.require_form(req)
let manual_review_enabled =
list.key_find(form_data.values, "manual_review_enabled")
|> result.map(fn(v) { v == "true" })
|> result.unwrap(False)
let schedule_enabled =
list.key_find(form_data.values, "schedule_enabled")
|> result.map(fn(v) { v == "true" })
|> result.unwrap(False)
let start_hour =
list.key_find(form_data.values, "start_hour")
|> result.try(int.parse)
|> result.unwrap(0)
let end_hour =
list.key_find(form_data.values, "end_hour")
|> result.try(int.parse)
|> result.unwrap(23)
let registration_alerts_webhook_url =
list.key_find(form_data.values, "registration_alerts_webhook_url")
|> result.unwrap("")
let system_alerts_webhook_url =
list.key_find(form_data.values, "system_alerts_webhook_url")
|> result.unwrap("")
case
instance_config.update_instance_config(
ctx,
session,
manual_review_enabled,
schedule_enabled,
start_hour,
end_hour,
registration_alerts_webhook_url,
system_alerts_webhook_url,
)
{
Ok(_) ->
flash.redirect_with_success(
ctx,
"/instance-config",
"Configuration updated successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
"/instance-config",
"Failed to update configuration",
)
}
}

View File

@@ -0,0 +1,53 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common
import fluxer_admin/components/flash
import fluxer_admin/pages/ban_management_page
import fluxer_admin/web.{type Context, type Session}
import gleam/option
import wisp.{type Request, type Response}
pub fn view(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
) -> Response {
ban_management_page.view(
ctx,
session,
current_admin,
flash_data,
ban_management_page.IpBan,
)
}
pub fn handle_action(
req: Request,
ctx: Context,
session: Session,
action: option.Option(String),
) -> Response {
ban_management_page.handle_action(
req,
ctx,
session,
ban_management_page.IpBan,
action,
)
}

View File

@@ -0,0 +1,602 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common
import fluxer_admin/components/flash
import fluxer_admin/components/layout
import fluxer_admin/components/ui
import fluxer_admin/web.{type Context, type Session, prepend_base_path}
import gleam/option.{type Option, None, Some}
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Response}
pub fn view(
ctx: Context,
session: Session,
current_admin: Option(common.UserLookupResult),
flash_data: Option(flash.Flash),
) -> Response {
let content = case ctx.metrics_endpoint {
None -> render_not_configured()
Some(_) -> render_dashboard(ctx)
}
let html =
layout.page(
"Jobs",
"jobs",
ctx,
session,
current_admin,
flash_data,
content,
)
wisp.html_response(element.to_document_string(html), 200)
}
fn render_not_configured() {
ui.stack("6", [
ui.heading_page("Jobs Dashboard"),
h.div(
[
a.class(
"bg-yellow-50 border border-yellow-200 rounded-lg p-6 text-center",
),
],
[
h.p([a.class("text-yellow-800")], [
element.text(
"Metrics service not configured. Set FLUXER_METRICS_HOST to enable.",
),
]),
],
),
])
}
fn render_dashboard(ctx: Context) {
let proxy_endpoint = prepend_base_path(ctx, "/api/metrics")
h.div([], [
ui.heading_page("Jobs Dashboard"),
h.div([a.class("mt-6")], [
h.div(
[a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")],
[
h.div([a.class("p-6")], [
ui.heading_section("Queue Overview"),
ui.text_small_muted("Current job queue totals across all workers"),
h.div([a.id("queue-stats-container"), a.class("mt-4")], [
h.div(
[
a.class("grid grid-cols-2 md:grid-cols-5 gap-4"),
],
[
render_loading_stat_card("Total Pending", "pending-count"),
render_loading_stat_card("Total Running", "running-count"),
render_loading_stat_card("Total Failed", "failed-count"),
render_loading_stat_card(
"Worker Utilization",
"concurrency-utilization",
),
render_loading_stat_card("Avg Wait Time", "avg-wait-time"),
],
),
]),
]),
],
),
]),
h.div([a.class("mt-6")], [
h.div(
[a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")],
[
h.div([a.class("p-6")], [
ui.heading_section("Redis Queue Sizes"),
ui.text_small_muted("Background job queues stored in Redis"),
h.div(
[
a.id("redis-queue-container"),
a.class("grid grid-cols-2 md:grid-cols-4 gap-4 mt-4"),
],
[
render_loading_stat_card(
"Asset Deletion",
"redis-asset-deletion",
),
render_loading_stat_card(
"Cloudflare Purge",
"redis-cloudflare-purge",
),
render_loading_stat_card(
"Bulk Message Deletion",
"redis-bulk-message-deletion",
),
render_loading_stat_card(
"Account Deletion",
"redis-account-deletion",
),
],
),
]),
],
),
]),
h.div([a.class("mt-6")], [
h.div(
[a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")],
[
h.div([a.class("p-6")], [
ui.heading_section("Per-Task Breakdown"),
ui.text_small_muted(
"Pending, running, success rate, and retries by task",
),
h.div([a.id("per-task-container"), a.class("mt-4")], [
h.div([a.class("text-neutral-500 text-sm")], [
element.text("Loading..."),
]),
]),
]),
],
),
]),
h.div([a.class("mt-6")], [
h.div(
[a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")],
[
h.div([a.class("p-6")], [
ui.heading_section("Job Throughput Over Time"),
ui.text_small_muted(
"Jobs completed per minute (successes, errors, retries)",
),
h.div([a.class("mt-4")], [
element.element(
"canvas",
[a.id("jobThroughputChart"), a.attribute("height", "250")],
[],
),
]),
]),
],
),
]),
h.div([a.class("mt-6")], [
h.div(
[a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")],
[
h.div([a.class("p-6")], [
ui.heading_section("Queue Depths Over Time"),
ui.text_small_muted("Pending and running job counts over time"),
h.div([a.class("mt-4")], [
element.element(
"canvas",
[a.id("queueDepthsChart"), a.attribute("height", "250")],
[],
),
]),
]),
],
),
]),
h.div([a.class("mt-6")], [
h.div(
[a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")],
[
h.div([a.class("p-6")], [
ui.heading_section("Job Latency Percentiles"),
ui.text_small_muted("Processing time distribution (p50, p90, p99)"),
h.div([a.class("mt-4")], [
element.element(
"canvas",
[a.id("latencyPercentilesChart"), a.attribute("height", "250")],
[],
),
]),
]),
],
),
]),
h.div([a.class("mt-6")], [
h.div(
[a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")],
[
h.div([a.class("p-6")], [
ui.heading_section("Cron Job Status"),
ui.text_small_muted("Scheduled job execution tracking"),
h.div([a.id("cron-status-container"), a.class("mt-4")], [
h.div([a.class("text-neutral-500 text-sm")], [
element.text("Loading..."),
]),
]),
]),
],
),
]),
h.div([a.class("mt-6")], [
h.div(
[a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")],
[
h.div([a.class("p-6")], [
ui.heading_section("Recent Job Errors"),
ui.text_small_muted("Tasks with recent failures"),
h.div([a.id("recent-errors-container"), a.class("mt-4")], [
h.div([a.class("text-neutral-500 text-sm")], [
element.text("Loading..."),
]),
]),
]),
],
),
]),
h.script([a.src("https://fluxerstatic.com/libs/chartjs/chart.min.js")], ""),
h.script([], render_jobs_script(proxy_endpoint)),
])
}
fn render_loading_stat_card(label: String, id: String) {
h.div([a.class("bg-neutral-50 rounded-lg p-4 border border-neutral-200")], [
h.div([a.class("text-xs text-neutral-600 uppercase tracking-wider mb-1")], [
element.text(label),
]),
h.div([a.id(id), a.class("text-base font-semibold text-neutral-900")], [
element.text("-"),
]),
])
}
fn render_jobs_script(metrics_endpoint: String) -> String {
"
(async function() {
const endpoint = '" <> metrics_endpoint <> "';
if (!endpoint) return;
const formatNumber = (n) => {
if (n === null || n === undefined) return '-';
return n.toLocaleString();
};
const formatMs = (ms) => {
if (ms === null || ms === undefined) return '-';
if (ms < 1000) return Math.round(ms) + 'ms';
if (ms < 60000) return (ms / 1000).toFixed(1) + 's';
return (ms / 60000).toFixed(1) + 'm';
};
const formatPercent = (n) => {
if (n === null || n === undefined) return '-';
return Math.round(n) + '%';
};
const formatTimeLabel = (ts) => {
const d = new Date(ts);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const formatRelativeTime = (date) => {
if (!date) return 'Never';
const diff = Date.now() - new Date(date).getTime();
if (diff < 60000) return 'Just now';
if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
return Math.floor(diff / 86400000) + 'd ago';
};
const alignData = (data, timestamps) => {
const map = new Map(data.map(d => [d.timestamp, d.value]));
return timestamps.map(ts => map.get(ts) ?? null);
};
const getLatestValue = (data) => {
if (!data || data.length === 0) return null;
const sorted = [...data].sort((a, b) => b.timestamp - a.timestamp);
return sorted[0]?.value ?? null;
};
try {
const [pendingResp, runningResp, failedResp, utilizationResp, waitTimeResp] = await Promise.all([
fetch(endpoint + '/query?metric=worker.queue.total_pending').then(r => r.json()),
fetch(endpoint + '/query?metric=worker.queue.total_running').then(r => r.json()),
fetch(endpoint + '/query?metric=worker.queue.total_failed').then(r => r.json()),
fetch(endpoint + '/query?metric=worker.concurrency.utilization_percent').then(r => r.json()),
fetch(endpoint + '/query?metric=worker.job.avg_wait_time_ms_total').then(r => r.json())
]);
document.getElementById('pending-count').textContent = formatNumber(getLatestValue(pendingResp.data));
document.getElementById('running-count').textContent = formatNumber(getLatestValue(runningResp.data));
document.getElementById('failed-count').textContent = formatNumber(getLatestValue(failedResp.data));
document.getElementById('concurrency-utilization').textContent = formatPercent(getLatestValue(utilizationResp.data));
document.getElementById('avg-wait-time').textContent = formatMs(getLatestValue(waitTimeResp.data));
} catch (e) {
console.error('Failed to load queue stats:', e);
}
try {
const [assetResp, cloudflareResp, bulkMsgResp, accountResp] = await Promise.all([
fetch(endpoint + '/query?metric=worker.redis_queue.asset_deletion').then(r => r.json()),
fetch(endpoint + '/query?metric=worker.redis_queue.cloudflare_purge').then(r => r.json()),
fetch(endpoint + '/query?metric=worker.redis_queue.bulk_message_deletion').then(r => r.json()),
fetch(endpoint + '/query?metric=worker.redis_queue.account_deletion').then(r => r.json())
]);
document.getElementById('redis-asset-deletion').textContent = formatNumber(getLatestValue(assetResp.data));
document.getElementById('redis-cloudflare-purge').textContent = formatNumber(getLatestValue(cloudflareResp.data));
document.getElementById('redis-bulk-message-deletion').textContent = formatNumber(getLatestValue(bulkMsgResp.data));
document.getElementById('redis-account-deletion').textContent = formatNumber(getLatestValue(accountResp.data));
} catch (e) {
console.error('Failed to load Redis queue stats:', e);
}
try {
const [pendingTaskResp, runningTaskResp, successTaskResp, errorTaskResp, retryTaskResp] = await Promise.all([
fetch(endpoint + '/query/aggregate?metric=worker.queue.pending&group_by=task').then(r => r.json()),
fetch(endpoint + '/query/aggregate?metric=worker.queue.running&group_by=task').then(r => r.json()),
fetch(endpoint + '/query/aggregate?metric=worker.job.success&group_by=task').then(r => r.json()),
fetch(endpoint + '/query/aggregate?metric=worker.job.error&group_by=task').then(r => r.json()),
fetch(endpoint + '/query/aggregate?metric=worker.job.retries&group_by=task').then(r => r.json())
]);
const container = document.getElementById('per-task-container');
const pendingBreakdown = pendingTaskResp.breakdown || [];
const runningBreakdown = runningTaskResp.breakdown || [];
const successBreakdown = successTaskResp.breakdown || [];
const errorBreakdown = errorTaskResp.breakdown || [];
const retryBreakdown = retryTaskResp.breakdown || [];
const taskSet = new Set([
...pendingBreakdown.map(e => e.label),
...runningBreakdown.map(e => e.label),
...successBreakdown.map(e => e.label),
...errorBreakdown.map(e => e.label)
]);
if (taskSet.size === 0) {
container.innerHTML = '<div class=\"text-neutral-500 text-sm\">No per-task data available</div>';
} else {
const tasks = Array.from(taskSet).sort();
const pendingMap = new Map(pendingBreakdown.map(e => [e.label, e.value]));
const runningMap = new Map(runningBreakdown.map(e => [e.label, e.value]));
const successMap = new Map(successBreakdown.map(e => [e.label, e.value]));
const errorMap = new Map(errorBreakdown.map(e => [e.label, e.value]));
const retryMap = new Map(retryBreakdown.map(e => [e.label, e.value]));
let html = '<div class=\"overflow-x-auto\"><table class=\"min-w-full divide-y divide-neutral-200\">';
html += '<thead class=\"bg-neutral-50\"><tr>';
html += '<th class=\"px-4 py-2 text-left text-xs font-medium text-neutral-500 uppercase\">Task</th>';
html += '<th class=\"px-4 py-2 text-right text-xs font-medium text-neutral-500 uppercase\">Pending</th>';
html += '<th class=\"px-4 py-2 text-right text-xs font-medium text-neutral-500 uppercase\">Running</th>';
html += '<th class=\"px-4 py-2 text-right text-xs font-medium text-neutral-500 uppercase\">Success</th>';
html += '<th class=\"px-4 py-2 text-right text-xs font-medium text-neutral-500 uppercase\">Errors</th>';
html += '<th class=\"px-4 py-2 text-right text-xs font-medium text-neutral-500 uppercase\">Success Rate</th>';
html += '<th class=\"px-4 py-2 text-right text-xs font-medium text-neutral-500 uppercase\">Retries</th>';
html += '</tr></thead><tbody class=\"bg-white divide-y divide-neutral-200\">';
for (const task of tasks) {
const pending = pendingMap.get(task) ?? 0;
const running = runningMap.get(task) ?? 0;
const success = successMap.get(task) ?? 0;
const errors = errorMap.get(task) ?? 0;
const retries = retryMap.get(task) ?? 0;
const total = success + errors;
const successRate = total > 0 ? ((success / total) * 100).toFixed(1) + '%' : '-';
const successRateClass = total > 0 ? (success / total >= 0.95 ? 'text-green-600' : success / total >= 0.8 ? 'text-yellow-600' : 'text-red-600') : 'text-neutral-600';
html += '<tr class=\"hover:bg-neutral-50\">';
html += '<td class=\"px-4 py-2 text-sm font-medium text-neutral-900\">' + task + '</td>';
html += '<td class=\"px-4 py-2 text-sm text-neutral-600 text-right\">' + formatNumber(pending) + '</td>';
html += '<td class=\"px-4 py-2 text-sm text-neutral-600 text-right\">' + formatNumber(running) + '</td>';
html += '<td class=\"px-4 py-2 text-sm text-green-600 text-right\">' + formatNumber(success) + '</td>';
html += '<td class=\"px-4 py-2 text-sm text-red-600 text-right\">' + formatNumber(errors) + '</td>';
html += '<td class=\"px-4 py-2 text-sm font-medium text-right ' + successRateClass + '\">' + successRate + '</td>';
html += '<td class=\"px-4 py-2 text-sm text-orange-600 text-right\">' + formatNumber(retries) + '</td>';
html += '</tr>';
}
html += '</tbody></table></div>';
container.innerHTML = html;
}
} catch (e) {
console.error('Failed to load per-task stats:', e);
document.getElementById('per-task-container').innerHTML = '<div class=\"text-red-500 text-sm\">Failed to load per-task data</div>';
}
try {
const [successResp, errorResp, retryResp] = await Promise.all([
fetch(endpoint + '/query?metric=worker.job.success').then(r => r.json()),
fetch(endpoint + '/query?metric=worker.job.error').then(r => r.json()),
fetch(endpoint + '/query?metric=worker.job.retry').then(r => r.json())
]);
const timestamps = Array.from(new Set([
...successResp.data.map(d => d.timestamp),
...errorResp.data.map(d => d.timestamp),
...(retryResp.data || []).map(d => d.timestamp),
])).sort((a, b) => a - b);
if (timestamps.length > 0) {
new Chart(document.getElementById('jobThroughputChart'), {
type: 'line',
data: {
labels: timestamps.map(formatTimeLabel),
datasets: [
{ label: 'Successes', data: alignData(successResp.data, timestamps), borderColor: 'rgb(34, 197, 94)', backgroundColor: 'rgba(34, 197, 94, 0.1)', fill: true, tension: 0.1, spanGaps: true },
{ label: 'Errors', data: alignData(errorResp.data, timestamps), borderColor: 'rgb(239, 68, 68)', backgroundColor: 'rgba(239, 68, 68, 0.1)', fill: true, tension: 0.1, spanGaps: true },
{ label: 'Retries', data: alignData(retryResp.data || [], timestamps), borderColor: 'rgb(249, 115, 22)', backgroundColor: 'rgba(249, 115, 22, 0.1)', fill: true, tension: 0.1, spanGaps: true }
]
},
options: {
responsive: true,
scales: { y: { beginAtZero: true } },
plugins: { legend: { position: 'top' } }
}
});
}
} catch (e) {
console.error('Failed to load job throughput chart:', e);
}
try {
const [pendingResp, runningResp] = await Promise.all([
fetch(endpoint + '/query?metric=worker.queue.total_pending').then(r => r.json()),
fetch(endpoint + '/query?metric=worker.queue.total_running').then(r => r.json())
]);
const timestamps = Array.from(new Set([
...pendingResp.data.map(d => d.timestamp),
...runningResp.data.map(d => d.timestamp),
])).sort((a, b) => a - b);
if (timestamps.length > 0) {
new Chart(document.getElementById('queueDepthsChart'), {
type: 'line',
data: {
labels: timestamps.map(formatTimeLabel),
datasets: [
{ label: 'Pending', data: alignData(pendingResp.data, timestamps), borderColor: 'rgb(59, 130, 246)', backgroundColor: 'rgba(59, 130, 246, 0.1)', fill: true, tension: 0.1, spanGaps: true },
{ label: 'Running', data: alignData(runningResp.data, timestamps), borderColor: 'rgb(168, 85, 247)', backgroundColor: 'rgba(168, 85, 247, 0.1)', fill: true, tension: 0.1, spanGaps: true }
]
},
options: {
responsive: true,
scales: { y: { beginAtZero: true } },
plugins: { legend: { position: 'top' } }
}
});
}
} catch (e) {
console.error('Failed to load queue depths chart:', e);
}
try {
const latencyResp = await fetch(endpoint + '/query/percentiles?metric=worker.job.latency').then(r => r.json());
const percentiles = latencyResp.percentiles || {};
if (Object.keys(percentiles).length > 0) {
const labels = Object.keys(percentiles).sort();
const p50Data = labels.map(task => percentiles[task]?.p50 ?? null);
const p90Data = labels.map(task => percentiles[task]?.p90 ?? null);
const p99Data = labels.map(task => percentiles[task]?.p99 ?? null);
new Chart(document.getElementById('latencyPercentilesChart'), {
type: 'bar',
data: {
labels: labels,
datasets: [
{ label: 'p50', data: p50Data, backgroundColor: 'rgba(59, 130, 246, 0.8)' },
{ label: 'p90', data: p90Data, backgroundColor: 'rgba(249, 115, 22, 0.8)' },
{ label: 'p99', data: p99Data, backgroundColor: 'rgba(239, 68, 68, 0.8)' }
]
},
options: {
responsive: true,
scales: {
y: {
beginAtZero: true,
title: { display: true, text: 'Latency (ms)' }
}
},
plugins: { legend: { position: 'top' } }
}
});
} else {
document.getElementById('latencyPercentilesChart').parentElement.innerHTML = '<div class=\"text-neutral-500 text-sm text-center py-8\">No latency data available</div>';
}
} catch (e) {
console.error('Failed to load latency percentiles chart:', e);
document.getElementById('latencyPercentilesChart').parentElement.innerHTML = '<div class=\"text-neutral-500 text-sm text-center py-8\">No latency data available</div>';
}
try {
const cronResp = await fetch(endpoint + '/query/aggregate?metric=worker.cron.last_run_age_ms&group_by=task').then(r => r.json());
const overdueResp = await fetch(endpoint + '/query/aggregate?metric=worker.cron.overdue&group_by=task').then(r => r.json());
const container = document.getElementById('cron-status-container');
const cronBreakdown = cronResp.breakdown || [];
const overdueBreakdown = overdueResp.breakdown || [];
const overdueMap = new Map(overdueBreakdown.map(e => [e.label, e.value]));
if (cronBreakdown.length === 0) {
container.innerHTML = '<div class=\"text-neutral-500 text-sm\">No cron job data available</div>';
} else {
let html = '<div class=\"overflow-x-auto\"><table class=\"min-w-full divide-y divide-neutral-200\">';
html += '<thead class=\"bg-neutral-50\"><tr>';
html += '<th class=\"px-4 py-2 text-left text-xs font-medium text-neutral-500 uppercase\">Cron Task</th>';
html += '<th class=\"px-4 py-2 text-right text-xs font-medium text-neutral-500 uppercase\">Last Run</th>';
html += '<th class=\"px-4 py-2 text-center text-xs font-medium text-neutral-500 uppercase\">Status</th>';
html += '</tr></thead><tbody class=\"bg-white divide-y divide-neutral-200\">';
for (const cron of cronBreakdown.sort((a, b) => a.label.localeCompare(b.label))) {
const isOverdue = overdueMap.get(cron.label) === 1;
const statusClass = isOverdue ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800';
const statusText = isOverdue ? 'Overdue' : 'OK';
html += '<tr class=\"hover:bg-neutral-50\">';
html += '<td class=\"px-4 py-2 text-sm font-medium text-neutral-900\">' + cron.label + '</td>';
html += '<td class=\"px-4 py-2 text-sm text-neutral-600 text-right\">' + formatMs(cron.value) + ' ago</td>';
html += '<td class=\"px-4 py-2 text-center\"><span class=\"px-2 py-1 text-xs font-medium rounded-full ' + statusClass + '\">' + statusText + '</span></td>';
html += '</tr>';
}
html += '</tbody></table></div>';
container.innerHTML = html;
}
} catch (e) {
console.error('Failed to load cron status:', e);
document.getElementById('cron-status-container').innerHTML = '<div class=\"text-red-500 text-sm\">Failed to load cron data</div>';
}
try {
const errorTaskResp = await fetch(endpoint + '/query/aggregate?metric=worker.job.error&group_by=task').then(r => r.json());
const permFailResp = await fetch(endpoint + '/query/aggregate?metric=worker.job.permanently_failed&group_by=task').then(r => r.json());
const container = document.getElementById('recent-errors-container');
const errorBreakdown = errorTaskResp.breakdown || [];
const permFailBreakdown = permFailResp.breakdown || [];
const permFailMap = new Map(permFailBreakdown.map(e => [e.label, e.value]));
const tasksWithErrors = errorBreakdown.filter(e => e.value > 0).sort((a, b) => b.value - a.value);
if (tasksWithErrors.length === 0) {
container.innerHTML = '<div class=\"text-green-600 text-sm flex items-center gap-2\"><svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-5 w-5\" viewBox=\"0 0 20 20\" fill=\"currentColor\"><path fill-rule=\"evenodd\" d=\"M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z\" clip-rule=\"evenodd\" /></svg> No recent errors</div>';
} else {
let html = '<div class=\"overflow-x-auto\"><table class=\"min-w-full divide-y divide-neutral-200\">';
html += '<thead class=\"bg-neutral-50\"><tr>';
html += '<th class=\"px-4 py-2 text-left text-xs font-medium text-neutral-500 uppercase\">Task</th>';
html += '<th class=\"px-4 py-2 text-right text-xs font-medium text-neutral-500 uppercase\">Total Errors</th>';
html += '<th class=\"px-4 py-2 text-right text-xs font-medium text-neutral-500 uppercase\">Permanently Failed</th>';
html += '</tr></thead><tbody class=\"bg-white divide-y divide-neutral-200\">';
for (const task of tasksWithErrors.slice(0, 10)) {
const permFailed = permFailMap.get(task.label) ?? 0;
html += '<tr class=\"hover:bg-neutral-50\">';
html += '<td class=\"px-4 py-2 text-sm font-medium text-neutral-900\">' + task.label + '</td>';
html += '<td class=\"px-4 py-2 text-sm text-red-600 text-right font-medium\">' + formatNumber(task.value) + '</td>';
html += '<td class=\"px-4 py-2 text-sm text-right ' + (permFailed > 0 ? 'text-red-800 font-bold' : 'text-neutral-600') + '\">' + formatNumber(permFailed) + '</td>';
html += '</tr>';
}
html += '</tbody></table></div>';
container.innerHTML = html;
}
} catch (e) {
console.error('Failed to load recent errors:', e);
document.getElementById('recent-errors-container').innerHTML = '<div class=\"text-red-500 text-sm\">Failed to load error data</div>';
}
})();
"
}

View File

@@ -0,0 +1,83 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/components/layout
import fluxer_admin/components/ui
import fluxer_admin/web.{type Context, href}
import gleam/option.{type Option, None, Some}
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Response}
pub fn view(ctx: Context, error: Option(String)) -> Response {
let html =
h.html([a.attribute("lang", "en")], [
layout.build_head("Admin Login", ctx),
h.body(
[
a.class(
"min-h-screen bg-neutral-50 flex items-center justify-center p-4",
),
],
[
h.div([a.class("w-full max-w-sm")], [
h.div(
[
a.class(
"bg-white border border-neutral-200 rounded-lg p-8 space-y-6",
),
],
[
h.h1(
[a.class("text-xl text-sm font-medium text-neutral-900 mb-6")],
[
element.text("Admin Login"),
],
),
case error {
Some(msg) ->
h.div(
[
a.class(
"bg-red-50 border border-red-200 text-red-600 px-3 py-2 rounded text-sm",
),
],
[element.text(msg)],
)
None -> element.none()
},
h.a([href(ctx, "/auth/start")], [
ui.button(
"Sign in with Fluxer",
"button",
ui.Primary,
ui.Medium,
ui.Full,
[],
),
]),
],
),
]),
],
),
element.none(),
])
wisp.html_response(element.to_document_string(html), 200)
}

View File

@@ -0,0 +1,572 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common
import fluxer_admin/api/metrics
import fluxer_admin/components/flash
import fluxer_admin/components/layout
import fluxer_admin/components/ui
import fluxer_admin/web.{type Context, type Session, prepend_base_path}
import gleam/float
import gleam/int
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/string
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Response}
pub fn view(
ctx: Context,
session: Session,
current_admin: Option(common.UserLookupResult),
flash_data: Option(flash.Flash),
) -> Response {
let content = case ctx.metrics_endpoint {
None -> render_not_configured()
Some(_) -> render_dashboard(ctx)
}
let html =
layout.page(
"Messaging & API Metrics",
"messages-metrics",
ctx,
session,
current_admin,
flash_data,
content,
)
wisp.html_response(element.to_document_string(html), 200)
}
fn render_not_configured() {
ui.stack("6", [
ui.heading_page("Messaging & API Metrics"),
h.div(
[
a.class(
"bg-yellow-50 border border-yellow-200 rounded-lg p-6 text-center",
),
],
[
h.p([a.class("text-yellow-800")], [
element.text(
"Metrics service not configured. Set FLUXER_METRICS_HOST to enable.",
),
]),
],
),
])
}
fn render_dashboard(ctx: Context) {
let messages_sent = metrics.query_aggregate(ctx, "message.send")
let messages_edited = metrics.query_aggregate(ctx, "message.edit")
let messages_deleted = metrics.query_aggregate(ctx, "message.delete")
let attachments_created = metrics.query_aggregate(ctx, "attachment.created")
let attachment_storage =
metrics.query_aggregate(ctx, "attachment.storage.bytes")
let proxy_endpoint = prepend_base_path(ctx, "/api/metrics")
h.div([], [
ui.heading_page("Messaging & API Metrics Dashboard"),
h.div([a.class("mt-6")], [
h.div(
[a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")],
[
h.div([a.class("p-6")], [
ui.heading_section("Cumulative Totals"),
ui.text_small_muted(
"Lifetime totals for message and attachment activity",
),
h.div(
[
a.class(
"grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mt-4",
),
],
[
render_stat_card("Messages Sent", messages_sent),
render_stat_card("Messages Edited", messages_edited),
render_stat_card("Messages Deleted", messages_deleted),
render_stat_card("Attachments Created", attachments_created),
render_storage_card(attachment_storage),
],
),
]),
],
),
]),
h.div([a.class("mt-6")], [
h.div(
[a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")],
[
h.div([a.class("p-6")], [
ui.heading_section("Attachment Volume"),
ui.text_small_muted(
"Attachment bytes sent over time - useful for detecting spikes in upload activity",
),
h.div([a.class("mt-4")], [
element.element(
"canvas",
[a.id("attachmentBytesChart"), a.attribute("height", "250")],
[],
),
]),
]),
],
),
]),
h.div([a.class("mt-6")], [
h.div(
[a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")],
[
h.div([a.class("p-6")], [
ui.heading_section("Message Activity"),
ui.text_small_muted(
"Message send, edit, and delete rates over time - useful for detecting unusual activity spikes",
),
h.div([a.class("mt-4")], [
element.element(
"canvas",
[a.id("messageActivityChart"), a.attribute("height", "250")],
[],
),
]),
]),
],
),
]),
h.div([a.class("mt-6")], [
h.div(
[a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")],
[
h.div([a.class("p-6")], [
ui.heading_section("Reactions"),
ui.text_small_muted("Reaction add and remove rates over time"),
h.div([a.class("mt-4")], [
element.element(
"canvas",
[a.id("reactionsChart"), a.attribute("height", "250")],
[],
),
]),
]),
],
),
]),
h.div([a.class("mt-6")], [
h.div(
[a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")],
[
h.div([a.class("p-6")], [
ui.heading_section("API Latency"),
ui.text_small_muted(
"API request latency percentiles (p50, p95, p99) over time",
),
h.div([a.id("latency-stats-container"), a.class("mt-4 mb-4")], [
h.div([a.class("grid grid-cols-1 md:grid-cols-3 gap-4")], [
render_loading_stat_card("Current P50", "latency-p50"),
render_loading_stat_card("Current P95", "latency-p95"),
render_loading_stat_card("Current P99", "latency-p99"),
]),
]),
h.div([a.class("mt-4")], [
element.element(
"canvas",
[a.id("latencyChart"), a.attribute("height", "250")],
[],
),
]),
]),
],
),
]),
h.div([a.class("mt-6")], [
h.div(
[a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")],
[
h.div([a.class("p-6")], [
ui.heading_section("API Response Status Codes"),
ui.text_small_muted(
"API response status code counts (2xx, 4xx, 5xx) over time",
),
h.div([a.class("mt-4")], [
element.element(
"canvas",
[a.id("statusCodesChart"), a.attribute("height", "250")],
[],
),
]),
]),
],
),
]),
h.script([a.src("https://fluxerstatic.com/libs/chartjs/chart.min.js")], ""),
h.script([], render_charts_script(proxy_endpoint)),
])
}
fn render_stat_card(
label: String,
result: Result(metrics.AggregateResponse, common.ApiError),
) {
let value = case result {
Ok(resp) -> format_number(resp.total)
Error(_) -> "-"
}
h.div([a.class("bg-neutral-50 rounded-lg p-4 border border-neutral-200")], [
h.div([a.class("text-xs text-neutral-600 uppercase tracking-wider mb-1")], [
element.text(label),
]),
h.div([a.class("text-base font-semibold text-neutral-900")], [
element.text(value),
]),
])
}
fn render_storage_card(
result: Result(metrics.AggregateResponse, common.ApiError),
) {
let value = case result {
Ok(resp) -> format_bytes(resp.total)
Error(_) -> "-"
}
h.div([a.class("bg-neutral-50 rounded-lg p-4 border border-neutral-200")], [
h.div([a.class("text-xs text-neutral-600 uppercase tracking-wider mb-1")], [
element.text("Storage Used"),
]),
h.div([a.class("text-base font-semibold text-neutral-900")], [
element.text(value),
]),
])
}
fn render_loading_stat_card(label: String, id: String) {
h.div([a.class("bg-neutral-50 rounded-lg p-4 border border-neutral-200")], [
h.div([a.class("text-xs text-neutral-600 uppercase tracking-wider mb-1")], [
element.text(label),
]),
h.div([a.id(id), a.class("text-base font-semibold text-neutral-900")], [
element.text("-"),
]),
])
}
fn format_number(n: Float) -> String {
let int_val = float.truncate(n)
format_int_with_commas(int_val)
}
fn format_int_with_commas(n: Int) -> String {
let s = int.to_string(n)
let len = string.length(s)
case len {
_ if len <= 3 -> s
_ -> {
let groups = reverse_groups(s, [])
string.join(list.reverse(groups), ",")
}
}
}
fn reverse_groups(s: String, acc: List(String)) -> List(String) {
let len = string.length(s)
case len {
0 -> acc
_ if len <= 3 -> [s, ..acc]
_ -> {
let group = string.slice(s, len - 3, 3)
let rest = string.slice(s, 0, len - 3)
reverse_groups(rest, [group, ..acc])
}
}
}
fn format_bytes(bytes: Float) -> String {
case bytes {
_ if bytes <. 1024.0 -> format_number(bytes) <> " B"
_ if bytes <. 1_048_576.0 -> {
let kb = bytes /. 1024.0
float_to_string_rounded(kb, 2) <> " KB"
}
_ if bytes <. 1_073_741_824.0 -> {
let mb = bytes /. 1_048_576.0
float_to_string_rounded(mb, 2) <> " MB"
}
_ -> {
let gb = bytes /. 1_073_741_824.0
float_to_string_rounded(gb, 2) <> " GB"
}
}
}
fn float_to_string_rounded(value: Float, decimals: Int) -> String {
let multiplier = case decimals {
0 -> 1.0
1 -> 10.0
2 -> 100.0
3 -> 1000.0
_ -> 100.0
}
let rounded = float.round(value *. multiplier) |> int.to_float
let result = rounded /. multiplier
case decimals {
0 -> {
let int_value = float.round(result)
int.to_string(int_value)
}
_ -> {
let str = float.to_string(result)
case string.contains(str, ".") {
True -> str
False -> str <> ".0"
}
}
}
}
fn render_charts_script(metrics_endpoint: String) -> String {
"
(async function() {
const endpoint = '" <> metrics_endpoint <> "';
if (!endpoint) return;
const formatBytes = (bytes) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const formatMs = (ms) => {
if (ms === null || ms === undefined) return '-';
return ms.toFixed(2) + ' ms';
};
const alignData = (data, timestamps) => {
const map = new Map(data.map(d => [d.timestamp, d.value]));
return timestamps.map(ts => map.get(ts) ?? null);
};
const formatTimeLabel = (ts) => {
const d = new Date(ts);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const getLatestValue = (data) => {
if (!data || data.length === 0) return null;
const sorted = [...data].sort((a, b) => b.timestamp - a.timestamp);
return sorted[0]?.value ?? null;
};
try {
const attachmentBytesResp = await fetch(endpoint + '/query?metric=attachment.storage.bytes').then(r => r.json());
const abTimestamps = attachmentBytesResp.data.map(d => d.timestamp).sort((a, b) => a - b);
if (abTimestamps.length > 0) {
new Chart(document.getElementById('attachmentBytesChart'), {
type: 'line',
data: {
labels: abTimestamps.map(formatTimeLabel),
datasets: [
{ label: 'Attachment Bytes', data: alignData(attachmentBytesResp.data, abTimestamps), borderColor: 'rgb(168, 85, 247)', backgroundColor: 'rgba(168, 85, 247, 0.1)', fill: true, tension: 0.1, spanGaps: true }
]
},
options: {
responsive: true,
scales: {
y: {
beginAtZero: true,
title: { display: true, text: 'Bytes' },
ticks: { callback: function(value) { return formatBytes(value); } }
}
},
plugins: {
legend: { position: 'top' },
tooltip: { callbacks: { label: function(context) { return context.dataset.label + ': ' + formatBytes(context.raw); } } }
}
}
});
}
} catch (e) {
console.error('Failed to load attachment bytes chart:', e);
}
try {
const [msgSendResp, msgEditResp, msgDeleteResp] = await Promise.all([
fetch(endpoint + '/query?metric=message.send').then(r => r.json()),
fetch(endpoint + '/query?metric=message.edit').then(r => r.json()),
fetch(endpoint + '/query?metric=message.delete').then(r => r.json())
]);
const maTimestamps = Array.from(new Set([
...msgSendResp.data.map(d => d.timestamp),
...msgEditResp.data.map(d => d.timestamp),
...msgDeleteResp.data.map(d => d.timestamp),
])).sort((a, b) => a - b);
if (maTimestamps.length > 0) {
new Chart(document.getElementById('messageActivityChart'), {
type: 'line',
data: {
labels: maTimestamps.map(formatTimeLabel),
datasets: [
{ label: 'Messages Sent', data: alignData(msgSendResp.data, maTimestamps), borderColor: 'rgb(34, 197, 94)', tension: 0.1, spanGaps: true },
{ label: 'Messages Edited', data: alignData(msgEditResp.data, maTimestamps), borderColor: 'rgb(59, 130, 246)', tension: 0.1, spanGaps: true },
{ label: 'Messages Deleted', data: alignData(msgDeleteResp.data, maTimestamps), borderColor: 'rgb(239, 68, 68)', tension: 0.1, spanGaps: true }
]
},
options: {
responsive: true,
scales: { y: { beginAtZero: true, title: { display: true, text: 'Count' } } },
plugins: { legend: { position: 'top' } }
}
});
}
} catch (e) {
console.error('Failed to load message activity chart:', e);
}
try {
const [reactionAddResp, reactionRemoveResp] = await Promise.all([
fetch(endpoint + '/query?metric=reaction.add').then(r => r.json()),
fetch(endpoint + '/query?metric=reaction.remove').then(r => r.json())
]);
const rxTimestamps = Array.from(new Set([
...reactionAddResp.data.map(d => d.timestamp),
...reactionRemoveResp.data.map(d => d.timestamp),
])).sort((a, b) => a - b);
if (rxTimestamps.length > 0) {
new Chart(document.getElementById('reactionsChart'), {
type: 'line',
data: {
labels: rxTimestamps.map(formatTimeLabel),
datasets: [
{ label: 'Reactions Added', data: alignData(reactionAddResp.data, rxTimestamps), borderColor: 'rgb(34, 197, 94)', tension: 0.1, spanGaps: true },
{ label: 'Reactions Removed', data: alignData(reactionRemoveResp.data, rxTimestamps), borderColor: 'rgb(239, 68, 68)', tension: 0.1, spanGaps: true }
]
},
options: {
responsive: true,
scales: { y: { beginAtZero: true, title: { display: true, text: 'Count' } } },
plugins: { legend: { position: 'top' } }
}
});
}
} catch (e) {
console.error('Failed to load reactions chart:', e);
}
try {
const latencyResp = await fetch(endpoint + '/query/percentiles?metric=api.latency').then(r => r.json());
const p50El = document.getElementById('latency-p50');
const p95El = document.getElementById('latency-p95');
const p99El = document.getElementById('latency-p99');
const percentiles = latencyResp.percentiles;
if (percentiles) {
if (p50El) p50El.textContent = formatMs(percentiles.p50);
if (p95El) p95El.textContent = formatMs(percentiles.p95);
if (p99El) p99El.textContent = formatMs(percentiles.p99);
new Chart(document.getElementById('latencyChart'), {
type: 'bar',
data: {
labels: ['P50', 'P95', 'P99'],
datasets: [
{
label: 'Latency',
data: [percentiles.p50, percentiles.p95, percentiles.p99],
backgroundColor: ['rgb(34, 197, 94)', 'rgb(251, 146, 60)', 'rgb(239, 68, 68)']
}
]
},
options: {
responsive: true,
scales: {
y: {
beginAtZero: true,
title: { display: true, text: 'Latency (ms)' },
ticks: { callback: function(value) { return value + ' ms'; } }
}
},
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: function(context) { return formatMs(context.raw); } } }
}
}
});
} else {
if (p50El) p50El.textContent = '-';
if (p95El) p95El.textContent = '-';
if (p99El) p99El.textContent = '-';
}
} catch (e) {
console.error('Failed to load latency chart:', e);
}
try {
const [status2xxResp, status4xxResp, status5xxResp] = await Promise.all([
fetch(endpoint + '/query?metric=api.request.2xx').then(r => r.json()),
fetch(endpoint + '/query?metric=api.request.4xx').then(r => r.json()),
fetch(endpoint + '/query?metric=api.request.5xx').then(r => r.json())
]);
const scTimestamps = Array.from(new Set([
...status2xxResp.data.map(d => d.timestamp),
...status4xxResp.data.map(d => d.timestamp),
...status5xxResp.data.map(d => d.timestamp),
])).sort((a, b) => a - b);
if (scTimestamps.length > 0) {
new Chart(document.getElementById('statusCodesChart'), {
type: 'line',
data: {
labels: scTimestamps.map(formatTimeLabel),
datasets: [
{ label: '2xx (Success)', data: alignData(status2xxResp.data, scTimestamps), borderColor: 'rgb(34, 197, 94)', tension: 0.1, spanGaps: true },
{ label: '4xx (Client Error)', data: alignData(status4xxResp.data, scTimestamps), borderColor: 'rgb(251, 146, 60)', tension: 0.1, spanGaps: true },
{ label: '5xx (Server Error)', data: alignData(status5xxResp.data, scTimestamps), borderColor: 'rgb(239, 68, 68)', tension: 0.1, spanGaps: true }
]
},
options: {
responsive: true,
scales: { y: { beginAtZero: true, title: { display: true, text: 'Request Count' } } },
plugins: { legend: { position: 'top' } }
}
});
}
} catch (e) {
console.error('Failed to load status codes chart:', e);
}
})();
"
}

View File

@@ -0,0 +1,523 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/messages
import fluxer_admin/components/flash
import fluxer_admin/components/layout
import fluxer_admin/components/message_list
import fluxer_admin/components/ui
import fluxer_admin/web.{type Context, type Session}
import gleam/int
import gleam/list
import gleam/option
import gleam/string
import gleam/uri
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Request, type Response}
pub fn view(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
admin_acls: List(String),
lookup_result: option.Option(messages.LookupMessageResponse),
prefill_channel_id: option.Option(String),
) -> Response {
let content =
h.div([a.class("space-y-6")], [
h.div([a.class("mb-6")], [ui.heading_page("Message Tools")]),
case lookup_result {
option.Some(result) -> render_lookup_result(ctx, result)
option.None -> element.none()
},
case acl.has_permission(admin_acls, "message:lookup") {
True -> render_lookup_message_form(prefill_channel_id)
False -> element.none()
},
case acl.has_permission(admin_acls, "message:lookup") {
True -> render_lookup_by_attachment_form()
False -> element.none()
},
case acl.has_permission(admin_acls, "message:delete") {
True -> render_delete_message_form()
False -> element.none()
},
])
let html =
layout.page(
"Message Tools",
"message-tools",
ctx,
session,
current_admin,
flash_data,
content,
)
let html_string = element.to_document_string(html)
let html_with_script =
string.replace(
html_string,
"</body>",
message_list.deletion_script() <> "</body>",
)
wisp.html_response(html_with_script, 200)
}
fn render_lookup_result(ctx: Context, result: messages.LookupMessageResponse) {
h.div([a.class("bg-white border border-neutral-200 rounded-lg p-6 mb-6")], [
ui.heading_card_with_margin("Lookup Result"),
h.div([a.class("mb-4 pb-4 border-b border-neutral-200")], [
h.span([a.class("text-sm text-neutral-600")], [
element.text("Searched for: "),
]),
h.span([a.class("text-sm text-neutral-900")], [
element.text(result.message_id),
]),
]),
case list.is_empty(result.messages) {
True ->
h.div([a.class("text-neutral-600 text-sm")], [
element.text("No messages found."),
])
False -> message_list.render(ctx, result.messages, True)
},
])
}
fn render_lookup_message_form(prefill_channel_id: option.Option(String)) {
h.div([a.class("bg-white border border-neutral-200 rounded-lg p-6")], [
ui.heading_card_with_margin("Lookup Message"),
h.form(
[a.method("POST"), a.action("?action=lookup"), a.class("space-y-4")],
[
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Channel ID"),
]),
h.input(
list.flatten([
[
a.type_("text"),
a.name("channel_id"),
a.placeholder("123456789"),
a.required(True),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
],
case prefill_channel_id {
option.Some(cid) -> [a.value(cid)]
option.None -> []
},
]),
),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Message ID"),
]),
h.input([
a.type_("text"),
a.name("message_id"),
a.placeholder("123456789"),
a.required(True),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
]),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Context Limit (messages before and after)"),
]),
h.input([
a.type_("number"),
a.name("context_limit"),
a.value("50"),
a.required(True),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
]),
]),
h.button(
[
a.type_("submit"),
a.class(
"w-full px-4 py-2 bg-neutral-900 text-white rounded text-sm font-medium hover:bg-neutral-800 transition-colors",
),
],
[element.text("Lookup Message")],
),
],
),
])
}
fn render_lookup_by_attachment_form() {
h.div([a.class("bg-white border border-neutral-200 rounded-lg p-6")], [
ui.heading_card_with_margin("Lookup Message by Attachment"),
h.form(
[
a.method("POST"),
a.action("?action=lookup-by-attachment"),
a.class("space-y-4"),
],
[
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Channel ID"),
]),
h.input([
a.type_("text"),
a.name("channel_id"),
a.placeholder("123456789"),
a.required(True),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
]),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Attachment ID"),
]),
h.input([
a.type_("text"),
a.name("attachment_id"),
a.placeholder("123456789"),
a.required(True),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
]),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Filename"),
]),
h.input([
a.type_("text"),
a.name("filename"),
a.placeholder("image.png"),
a.required(True),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
]),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Context Limit (messages before and after)"),
]),
h.input([
a.type_("number"),
a.name("context_limit"),
a.value("50"),
a.required(True),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
]),
]),
h.button(
[
a.type_("submit"),
a.class(
"w-full px-4 py-2 bg-neutral-900 text-white rounded text-sm font-medium hover:bg-neutral-800 transition-colors",
),
],
[element.text("Lookup by Attachment")],
),
],
),
])
}
fn render_delete_message_form() {
h.div([a.class("bg-white border border-neutral-200 rounded-lg p-6")], [
ui.heading_card_with_margin("Delete Message"),
h.form(
[
a.method("POST"),
a.action("?action=delete"),
a.class("space-y-4"),
a.attribute(
"onsubmit",
"return confirm('Are you sure you want to delete this message?')",
),
],
[
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Channel ID"),
]),
h.input([
a.type_("text"),
a.name("channel_id"),
a.placeholder("123456789"),
a.required(True),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
]),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Message ID"),
]),
h.input([
a.type_("text"),
a.name("message_id"),
a.placeholder("123456789"),
a.required(True),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
]),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("text-sm font-medium text-neutral-700 mb-2 block")], [
element.text("Audit Log Reason (optional)"),
]),
h.input([
a.type_("text"),
a.name("audit_log_reason"),
a.placeholder("Reason for deletion"),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
]),
]),
h.button(
[
a.type_("submit"),
a.class(
"w-full px-4 py-2 bg-red-600 text-white rounded text-sm font-medium hover:bg-red-700 transition-colors",
),
],
[element.text("Delete Message")],
),
],
),
])
}
pub fn handle_get(
req: Request,
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
admin_acls: List(String),
) -> Response {
let query = wisp.get_query(req)
let channel_id = list.key_find(query, "channel_id") |> option.from_result
let message_id = list.key_find(query, "message_id") |> option.from_result
let attachment_id =
list.key_find(query, "attachment_id") |> option.from_result
let filename = list.key_find(query, "filename") |> option.from_result
let context_limit =
list.key_find(query, "context_limit")
|> option.from_result
|> option.then(fn(s) { int.parse(s) |> option.from_result })
|> option.unwrap(50)
case channel_id, attachment_id, filename {
option.Some(cid), option.Some(aid), option.Some(fname) -> {
case
messages.lookup_message_by_attachment(
ctx,
session,
cid,
aid,
fname,
context_limit,
)
{
Ok(result) ->
view(
ctx,
session,
current_admin,
flash_data,
admin_acls,
option.Some(result),
option.None,
)
Error(_) ->
view(
ctx,
session,
current_admin,
flash_data,
admin_acls,
option.None,
option.Some(cid),
)
}
}
_, _, _ -> {
case channel_id, message_id {
option.Some(cid), option.Some(mid) -> {
case messages.lookup_message(ctx, session, cid, mid, context_limit) {
Ok(result) ->
view(
ctx,
session,
current_admin,
flash_data,
admin_acls,
option.Some(result),
option.None,
)
Error(_) ->
view(
ctx,
session,
current_admin,
flash_data,
admin_acls,
option.None,
option.Some(cid),
)
}
}
_, _ ->
view(
ctx,
session,
current_admin,
flash_data,
admin_acls,
option.None,
channel_id,
)
}
}
}
}
pub fn handle_action(
req: Request,
ctx: Context,
session: Session,
_admin_acls: List(String),
action: option.Option(String),
) -> Response {
use form_data <- wisp.require_form(req)
case action {
option.Some("lookup") -> {
let channel_id =
list.key_find(form_data.values, "channel_id") |> option.from_result
let message_id =
list.key_find(form_data.values, "message_id") |> option.from_result
let context_limit =
list.key_find(form_data.values, "context_limit")
|> option.from_result
|> option.unwrap("50")
case channel_id, message_id {
option.Some(cid), option.Some(mid) -> {
wisp.redirect(web.prepend_base_path(
ctx,
"/messages?channel_id="
<> cid
<> "&message_id="
<> mid
<> "&context_limit="
<> context_limit,
))
}
_, _ -> wisp.redirect(web.prepend_base_path(ctx, "/messages"))
}
}
option.Some("lookup-by-attachment") -> {
let channel_id =
list.key_find(form_data.values, "channel_id") |> option.from_result
let attachment_id =
list.key_find(form_data.values, "attachment_id") |> option.from_result
let filename =
list.key_find(form_data.values, "filename") |> option.from_result
let context_limit =
list.key_find(form_data.values, "context_limit")
|> option.from_result
|> option.unwrap("50")
case channel_id, attachment_id, filename {
option.Some(cid), option.Some(aid), option.Some(fname) -> {
let encoded_filename = uri.percent_encode(fname)
wisp.redirect(web.prepend_base_path(
ctx,
"/messages?channel_id="
<> cid
<> "&attachment_id="
<> aid
<> "&filename="
<> encoded_filename
<> "&context_limit="
<> context_limit,
))
}
_, _, _ -> wisp.redirect(web.prepend_base_path(ctx, "/messages"))
}
}
option.Some("delete") -> {
let channel_id =
list.key_find(form_data.values, "channel_id") |> option.from_result
let message_id =
list.key_find(form_data.values, "message_id") |> option.from_result
let audit_log_reason =
list.key_find(form_data.values, "audit_log_reason")
|> option.from_result
case channel_id, message_id {
option.Some(cid), option.Some(mid) -> {
case
messages.delete_message(ctx, session, cid, mid, audit_log_reason)
{
Ok(_) ->
wisp.response(200)
|> wisp.set_header("content-type", "application/json")
|> wisp.string_body("{\"success\":true}")
Error(_) ->
wisp.response(500)
|> wisp.set_header("content-type", "application/json")
|> wisp.string_body("{\"success\":false}")
}
}
_, _ ->
wisp.response(400)
|> wisp.set_header("content-type", "application/json")
|> wisp.string_body("{\"success\":false}")
}
}
_ -> wisp.redirect(web.prepend_base_path(ctx, "/messages"))
}
}

View File

@@ -0,0 +1,873 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common
import fluxer_admin/api/metrics
import fluxer_admin/components/flash
import fluxer_admin/components/layout
import fluxer_admin/components/ui
import fluxer_admin/web.{type Context, type Session, href, prepend_base_path}
import gleam/float
import gleam/int
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/order
import gleam/string
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Response}
pub fn view(
ctx: Context,
session: Session,
current_admin: Option(common.UserLookupResult),
flash_data: Option(flash.Flash),
) -> Response {
let content = case ctx.metrics_endpoint {
None -> render_not_configured()
Some(_) -> render_dashboard(ctx)
}
let html =
layout.page(
"Metrics Overview",
"metrics",
ctx,
session,
current_admin,
flash_data,
content,
)
wisp.html_response(element.to_document_string(html), 200)
}
fn render_not_configured() {
ui.stack("6", [
ui.heading_page("Metrics Overview"),
h.div(
[
a.class(
"bg-yellow-50 border border-yellow-200 rounded-lg p-6 text-center",
),
],
[
h.p([a.class("text-yellow-800")], [
element.text(
"Metrics service not configured. Set FLUXER_METRICS_HOST to enable.",
),
]),
],
),
])
}
fn render_dashboard(ctx: Context) {
let registrations = metrics.query_aggregate(ctx, "user.registration")
let messages = metrics.query_aggregate(ctx, "message.send")
let message_deletes = metrics.query_aggregate(ctx, "message.delete")
let guilds_created = metrics.query_aggregate(ctx, "guild.create")
let gateway_ready = metrics.query_aggregate(ctx, "gateway.ready")
let attachments = metrics.query_aggregate(ctx, "attachment.created")
let attachment_storage =
metrics.query_aggregate(ctx, "attachment.storage.bytes")
let reports_created = metrics.query_aggregate(ctx, "reports.iar.created")
let reports_resolved = metrics.query_aggregate(ctx, "reports.iar.resolved")
let age_distribution =
metrics.query_aggregate_grouped(ctx, "user.age", option.Some("age_group"))
let registration_by_state =
metrics.query_aggregate_grouped(
ctx,
"user.registration",
option.Some("state"),
)
let registration_by_country =
metrics.query_aggregate_grouped(
ctx,
"user.registration",
option.Some("country"),
)
let top_guilds = metrics.query_top(ctx, "guild.member_count", 6)
let top_users = metrics.query_top(ctx, "user.guild_membership_count", 6)
let crashes = metrics.query_crashes(ctx, 5)
let proxy_endpoint = prepend_base_path(ctx, "/api/metrics")
h.div([], [
ui.flex_row_between([
ui.heading_page("Platform Overview"),
h.div([a.class("flex gap-2")], [
render_quick_link(ctx, "Gateway", "/gateway"),
render_quick_link(ctx, "Jobs", "/jobs"),
render_quick_link(ctx, "Messaging & API", "/messages-metrics"),
]),
]),
render_platform_health_section(ctx, proxy_endpoint),
render_key_metrics_section(proxy_endpoint),
h.div([a.class("mt-8")], [
h.h2([a.class("text-base font-semibold text-neutral-900 mb-4")], [
element.text("Activity Highlights"),
]),
h.div([a.class("grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4")], [
render_stat_card("User Registrations", registrations),
render_stat_card("Messages Sent", messages),
render_stat_card("Message Deletes", message_deletes),
render_stat_card("Guilds Created", guilds_created),
render_stat_card("Gateway Connections", gateway_ready),
render_stat_card("Attachments Uploaded", attachments),
]),
]),
h.div([a.class("mt-6")], [
h.div([a.class("grid grid-cols-1 md:grid-cols-2 gap-4")], [
render_storage_card(attachment_storage),
render_report_rate_card(reports_created, reports_resolved),
]),
]),
render_recent_alerts_section(crashes),
h.div([a.class("mt-8")], [
h.h2([a.class("text-base font-semibold text-neutral-900 mb-4")], [
element.text("Activity Over Time"),
]),
h.div([a.class("bg-white border border-neutral-200 rounded-lg p-4")], [
element.element(
"canvas",
[a.id("activityChart"), a.attribute("height", "250")],
[],
),
]),
]),
h.div([a.class("mt-8 grid grid-cols-1 lg:grid-cols-3 gap-4")], [
h.div([a.class("bg-white/70 border border-neutral-200 rounded-lg p-4")], [
h.h3([a.class("text-base font-semibold text-neutral-900 mb-2")], [
element.text("Age Distribution"),
]),
render_age_breakdown(age_distribution),
]),
h.div([a.class("bg-white/70 border border-neutral-200 rounded-lg p-4")], [
h.h3([a.class("text-base font-semibold text-neutral-900 mb-2")], [
element.text("Guilds With Most Members"),
]),
render_top_list(top_guilds),
]),
h.div([a.class("bg-white/70 border border-neutral-200 rounded-lg p-4")], [
h.h3([a.class("text-base font-semibold text-neutral-900 mb-2")], [
element.text("Users In Most Guilds"),
]),
render_top_list(top_users),
]),
]),
h.div([a.class("mt-8 grid grid-cols-1 lg:grid-cols-2 gap-4")], [
h.div([a.class("bg-white/70 border border-neutral-200 rounded-lg p-4")], [
h.h3([a.class("text-base font-semibold text-neutral-900 mb-2")], [
element.text("Registrations by State"),
]),
render_registration_breakdown(registration_by_state),
]),
h.div([a.class("bg-white/70 border border-neutral-200 rounded-lg p-4")], [
h.h3([a.class("text-base font-semibold text-neutral-900 mb-2")], [
element.text("Registrations by Country"),
]),
render_registration_breakdown(registration_by_country),
]),
]),
h.script([a.src("https://fluxerstatic.com/libs/chartjs/chart.min.js")], ""),
h.script([], render_dashboard_script(proxy_endpoint)),
])
}
fn render_quick_link(ctx: Context, label: String, path: String) {
h.a(
[
href(ctx, path),
a.class(
"px-3 py-1.5 text-sm font-medium text-neutral-700 hover:text-neutral-900 border border-neutral-300 rounded hover:border-neutral-400 transition-colors",
),
],
[element.text(label)],
)
}
fn render_platform_health_section(ctx: Context, proxy_endpoint: String) {
h.div([a.class("mt-6")], [
h.div([a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")], [
h.div([a.class("p-6")], [
h.div([a.class("flex items-center justify-between mb-4")], [
ui.heading_section("Platform Health"),
h.a(
[
href(ctx, "/gateway"),
a.class(
"text-sm text-blue-600 hover:text-blue-800 hover:underline",
),
],
[element.text("View Gateway Details")],
),
]),
ui.text_small_muted(
"Real-time platform status and key health indicators",
),
h.div(
[a.class("grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mt-4")],
[
render_health_stat_card("Active Sessions", "health-sessions"),
render_health_stat_card("Guilds in Memory", "health-guilds"),
render_health_stat_card("API Requests/min", "health-rpm"),
render_health_stat_card("Error Rate", "health-error-rate"),
render_health_stat_card("Pending Jobs", "health-pending-jobs"),
],
),
]),
]),
h.script([], render_health_script(proxy_endpoint)),
])
}
fn render_health_stat_card(label: String, id: String) {
h.div([a.class("bg-neutral-50 rounded-lg p-4 border border-neutral-200")], [
h.div([a.class("text-xs text-neutral-600 uppercase tracking-wider mb-1")], [
element.text(label),
]),
h.div([a.id(id), a.class("text-base font-semibold text-neutral-900")], [
element.text("-"),
]),
])
}
fn render_key_metrics_section(proxy_endpoint: String) {
h.div([a.class("mt-6")], [
h.div([a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")], [
h.div([a.class("p-6")], [
ui.heading_section("Key Metrics At-a-Glance"),
ui.text_small_muted("Trend indicators showing recent activity patterns"),
h.div([a.class("grid grid-cols-2 md:grid-cols-4 gap-4 mt-4")], [
render_trend_card(
"User Registrations",
"trend-registrations",
"trend-registrations-indicator",
),
render_trend_card(
"Message Volume",
"trend-messages",
"trend-messages-indicator",
),
render_trend_card(
"Storage Used",
"trend-storage",
"trend-storage-indicator",
),
render_trend_card(
"API Latency (P95)",
"trend-latency",
"trend-latency-indicator",
),
]),
]),
]),
h.script([], render_trends_script(proxy_endpoint)),
])
}
fn render_trend_card(label: String, value_id: String, indicator_id: String) {
h.div([a.class("bg-neutral-50 rounded-lg p-4 border border-neutral-200")], [
h.div([a.class("text-xs text-neutral-600 uppercase tracking-wider mb-1")], [
element.text(label),
]),
h.div([a.class("flex items-center gap-2")], [
h.div(
[a.id(value_id), a.class("text-base font-semibold text-neutral-900")],
[element.text("-")],
),
h.div([a.id(indicator_id), a.class("text-xs")], []),
]),
])
}
fn render_recent_alerts_section(
crashes: Result(metrics.CrashesResponse, common.ApiError),
) {
h.div([a.class("mt-8")], [
h.div([a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")], [
h.div([a.class("p-6")], [
ui.heading_section("Recent Alerts"),
ui.text_small_muted("Recent guild crashes and system anomalies"),
h.div([a.class("mt-4")], [
case crashes {
Ok(resp) -> render_alerts_list(resp.crashes)
Error(_) ->
h.div([a.class("text-neutral-500 text-sm")], [
element.text("Unable to load alert data"),
])
},
]),
]),
]),
])
}
fn render_alerts_list(crashes: List(metrics.CrashEvent)) {
case list.length(crashes) {
0 ->
h.div([a.class("text-green-600 text-sm py-2")], [
element.text("No recent alerts"),
])
_ -> h.div([a.class("space-y-2")], list.map(crashes, render_alert_item))
}
}
fn render_alert_item(crash: metrics.CrashEvent) {
let time_str = format_timestamp(crash.timestamp)
let error_preview =
string.slice(crash.stacktrace, 0, 60)
<> case string.length(crash.stacktrace) > 60 {
True -> "..."
False -> ""
}
h.div(
[
a.class(
"flex items-center gap-3 p-3 bg-red-50 border border-red-100 rounded-lg",
),
],
[
h.div(
[
a.class("flex-shrink-0 w-2 h-2 rounded-full bg-red-500"),
],
[],
),
h.div([a.class("flex-1 min-w-0")], [
h.div([a.class("flex items-center gap-2")], [
h.span([a.class("text-sm font-medium text-red-900")], [
element.text("Guild crash"),
]),
h.span([a.class("text-xs text-red-600")], [element.text(time_str)]),
]),
h.div([a.class("text-xs text-red-700 truncate font-mono mt-1")], [
element.text(error_preview),
]),
]),
h.div([a.class("flex-shrink-0 text-xs text-red-600 font-mono")], [
element.text(crash.guild_id),
]),
],
)
}
fn render_stat_card(
label: String,
result: Result(metrics.AggregateResponse, common.ApiError),
) {
let value = case result {
Ok(resp) -> format_number(resp.total)
Error(_) -> "-"
}
h.div([a.class("bg-white border border-neutral-200 rounded-lg p-4")], [
h.p([a.class("text-sm text-neutral-500 mb-1")], [element.text(label)]),
h.p([a.class("text-2xl font-semibold text-neutral-900")], [
element.text(value),
]),
])
}
fn render_storage_card(
result: Result(metrics.AggregateResponse, common.ApiError),
) {
let value = case result {
Ok(resp) -> format_bytes(resp.total)
Error(_) -> "-"
}
h.div([a.class("bg-white border border-neutral-200 rounded-lg p-4")], [
h.p([a.class("text-sm text-neutral-500 mb-1")], [
element.text("Data Stored"),
]),
h.p([a.class("text-2xl font-semibold text-neutral-900")], [
element.text(value),
]),
])
}
fn render_report_rate_card(
created: Result(metrics.AggregateResponse, common.ApiError),
resolved: Result(metrics.AggregateResponse, common.ApiError),
) {
let rate = case created, resolved {
Ok(created_resp), Ok(resolved_resp) -> {
let total_created = created_resp.total
let total_resolved = resolved_resp.total
let percentage = case total_created >. 0.0 {
True -> {
let ratio = total_resolved /. total_created
ratio *. 100.0
}
False -> 0.0
}
format_percentage(percentage)
}
_, _ -> "-"
}
h.div([a.class("bg-white border border-neutral-200 rounded-lg p-4")], [
h.p([a.class("text-sm text-neutral-500 mb-1")], [
element.text("Reports Resolved"),
]),
h.p([a.class("text-2xl font-semibold text-neutral-900")], [
element.text(rate),
]),
])
}
fn render_age_breakdown(
result: Result(metrics.AggregateResponse, common.ApiError),
) {
case result {
Ok(resp) ->
case resp.breakdown {
Some(breakdown) ->
h.ul(
[a.class("space-y-2 text-sm text-neutral-700")],
list.map(breakdown, render_age_row),
)
None ->
h.div([a.class("text-neutral-500 text-sm")], [
element.text("No age data available"),
])
}
Error(_) ->
h.div([a.class("text-neutral-500 text-sm")], [
element.text("Unable to load age data"),
])
}
}
fn render_registration_breakdown(
result: Result(metrics.AggregateResponse, common.ApiError),
) {
case result {
Ok(resp) ->
case resp.breakdown {
Some(breakdown) -> {
let sorted_breakdown =
breakdown
|> list.sort(fn(a, b) {
case b.value, a.value {
b_val, a_val ->
case float.compare(b_val, a_val) {
order.Gt -> order.Lt
order.Lt -> order.Gt
order.Eq -> order.Eq
}
}
})
h.div([a.class("max-h-64 overflow-y-auto")], [
h.ul(
[a.class("space-y-1 text-sm text-neutral-700")],
list.take(sorted_breakdown, 20)
|> list.map(render_breakdown_row),
),
])
}
None ->
h.div([a.class("text-neutral-500 text-sm")], [
element.text("No registration data available"),
])
}
Error(_) ->
h.div([a.class("text-neutral-500 text-sm")], [
element.text("Unable to load registration data"),
])
}
}
fn render_age_row(entry: metrics.TopEntry) {
h.li([], [
h.span([a.class("font-medium text-neutral-900 block")], [
element.text(entry.label),
]),
h.span([a.class("text-xs text-neutral-500")], [
element.text(format_number(entry.value)),
]),
])
}
fn render_breakdown_row(entry: metrics.TopEntry) {
h.li(
[
a.class(
"flex justify-between py-1 border-b border-neutral-100 last:border-0",
),
],
[
h.span([a.class("text-neutral-700")], [element.text(entry.label)]),
h.span([a.class("font-semibold text-neutral-900")], [
element.text(format_number(entry.value)),
]),
],
)
}
fn render_top_list(result: Result(metrics.TopQueryResponse, common.ApiError)) {
case result {
Ok(resp) ->
case list.length(resp.entries) {
0 ->
h.div([a.class("text-neutral-500 text-sm")], [
element.text("No data available"),
])
_ ->
h.div(
[a.class("overflow-hidden rounded-lg border border-neutral-100")],
[
h.ul(
[a.class("divide-y divide-neutral-100")],
list.map(resp.entries, render_top_entry),
),
],
)
}
Error(_) ->
h.div([a.class("text-neutral-500 text-sm")], [
element.text("Unable to load ranking"),
])
}
}
fn render_top_entry(entry: metrics.TopEntry) {
h.li([a.class("flex justify-between px-3 py-2 text-sm")], [
h.span([a.class("text-neutral-700")], [element.text(entry.label)]),
h.span([a.class("font-semibold text-neutral-900")], [
element.text(format_number(entry.value)),
]),
])
}
fn render_health_script(metrics_endpoint: String) -> String {
"
(async function() {
const endpoint = '" <> metrics_endpoint <> "';
if (!endpoint) return;
const formatNumber = (n) => {
if (n === null || n === undefined) return '-';
return n.toLocaleString();
};
const getLatestValue = (data) => {
if (!data || data.length === 0) return null;
const sorted = [...data].sort((a, b) => b.timestamp - a.timestamp);
return sorted[0]?.value ?? null;
};
try {
const [sessionsResp, guildsResp, status2xxResp, status4xxResp, status5xxResp, pendingResp] = await Promise.all([
fetch(endpoint + '/query?metric=gateway.sessions.count').then(r => r.json()),
fetch(endpoint + '/query?metric=gateway.guilds.count').then(r => r.json()),
fetch(endpoint + '/query?metric=api.request.2xx').then(r => r.json()),
fetch(endpoint + '/query?metric=api.request.4xx').then(r => r.json()),
fetch(endpoint + '/query?metric=api.request.5xx').then(r => r.json()),
fetch(endpoint + '/query?metric=worker.queue.total_pending').then(r => r.json())
]);
const sessions = getLatestValue(sessionsResp.data);
const guilds = getLatestValue(guildsResp.data);
const pending = getLatestValue(pendingResp.data);
document.getElementById('health-sessions').textContent = formatNumber(sessions);
document.getElementById('health-guilds').textContent = formatNumber(guilds);
document.getElementById('health-pending-jobs').textContent = formatNumber(pending);
const recent2xx = status2xxResp.data.slice(-10);
const recent4xx = status4xxResp.data.slice(-10);
const recent5xx = status5xxResp.data.slice(-10);
const sum2xx = recent2xx.reduce((acc, d) => acc + d.value, 0);
const sum4xx = recent4xx.reduce((acc, d) => acc + d.value, 0);
const sum5xx = recent5xx.reduce((acc, d) => acc + d.value, 0);
const total = sum2xx + sum4xx + sum5xx;
const rpm = Math.round(total / 10);
document.getElementById('health-rpm').textContent = formatNumber(rpm);
const errorRate = total > 0 ? ((sum4xx + sum5xx) / total * 100).toFixed(1) : '0.0';
const errorEl = document.getElementById('health-error-rate');
errorEl.textContent = errorRate + '%';
if (parseFloat(errorRate) > 5) {
errorEl.classList.add('text-red-600');
} else if (parseFloat(errorRate) > 1) {
errorEl.classList.add('text-yellow-600');
} else {
errorEl.classList.add('text-green-600');
}
} catch (e) {
console.error('Failed to load health stats:', e);
}
})();
"
}
fn render_trends_script(metrics_endpoint: String) -> String {
"
(async function() {
const endpoint = '" <> metrics_endpoint <> "';
if (!endpoint) return;
const formatNumber = (n) => {
if (n === null || n === undefined) return '-';
return n.toLocaleString();
};
const formatBytes = (bytes) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const getTrend = (data, count) => {
if (!data || data.length < 2) return { current: null, trend: 'stable' };
const sorted = [...data].sort((a, b) => b.timestamp - a.timestamp);
const recent = sorted.slice(0, Math.min(count, sorted.length));
const older = sorted.slice(count, Math.min(count * 2, sorted.length));
const recentSum = recent.reduce((acc, d) => acc + d.value, 0);
const olderSum = older.length > 0 ? older.reduce((acc, d) => acc + d.value, 0) : recentSum;
let trend = 'stable';
if (olderSum > 0) {
const change = (recentSum - olderSum) / olderSum;
if (change > 0.1) trend = 'up';
else if (change < -0.1) trend = 'down';
}
return { current: recentSum, trend };
};
const renderTrend = (indicator, trend, isGood) => {
if (trend === 'up') {
indicator.textContent = isGood ? '↑' : '↑';
indicator.className = 'text-xs ' + (isGood ? 'text-green-600' : 'text-red-600');
} else if (trend === 'down') {
indicator.textContent = isGood ? '↓' : '↓';
indicator.className = 'text-xs ' + (isGood ? 'text-red-600' : 'text-green-600');
} else {
indicator.textContent = '→';
indicator.className = 'text-xs text-neutral-400';
}
};
try {
const [regResp, msgResp, storageResp, latencyResp] = await Promise.all([
fetch(endpoint + '/query?metric=user.registration').then(r => r.json()),
fetch(endpoint + '/query?metric=message.send').then(r => r.json()),
fetch(endpoint + '/query?metric=attachment.storage.bytes').then(r => r.json()),
fetch(endpoint + '/query/percentiles?metric=api.latency').then(r => r.json())
]);
const regTrend = getTrend(regResp.data, 5);
document.getElementById('trend-registrations').textContent = formatNumber(regTrend.current);
renderTrend(document.getElementById('trend-registrations-indicator'), regTrend.trend, true);
const msgTrend = getTrend(msgResp.data, 5);
document.getElementById('trend-messages').textContent = formatNumber(msgTrend.current);
renderTrend(document.getElementById('trend-messages-indicator'), msgTrend.trend, true);
const storageSorted = [...storageResp.data].sort((a, b) => b.timestamp - a.timestamp);
const currentStorage = storageSorted[0]?.value ?? 0;
document.getElementById('trend-storage').textContent = formatBytes(currentStorage);
renderTrend(document.getElementById('trend-storage-indicator'), 'stable', true);
const currentLatency = latencyResp.percentiles?.p95 ?? 0;
document.getElementById('trend-latency').textContent = currentLatency.toFixed(1) + ' ms';
const latencyTrend = currentLatency > 0 ? 'stable' : 'stable';
renderTrend(document.getElementById('trend-latency-indicator'), latencyTrend, false);
} catch (e) {
console.error('Failed to load trend stats:', e);
}
})();
"
}
fn render_dashboard_script(metrics_endpoint: String) -> String {
"
(async function() {
const endpoint = '" <> metrics_endpoint <> "';
if (!endpoint) return;
try {
const [regResp, msgResp, delResp, attachResp] = await Promise.all([
fetch(endpoint + '/query?metric=user.registration').then(r => r.json()),
fetch(endpoint + '/query?metric=message.send').then(r => r.json()),
fetch(endpoint + '/query?metric=message.delete').then(r => r.json()),
fetch(endpoint + '/query?metric=attachment.created').then(r => r.json())
]);
const timestamps = Array.from(new Set([
...regResp.data.map(d => d.timestamp),
...msgResp.data.map(d => d.timestamp),
...delResp.data.map(d => d.timestamp),
...attachResp.data.map(d => d.timestamp),
])).sort((a, b) => a - b);
const labels = timestamps.map(ts => new Date(ts).toLocaleDateString());
const alignData = (data) => {
const map = new Map(data.map(d => [d.timestamp, d.value]));
return timestamps.map(ts => map.get(ts) ?? 0);
};
new Chart(document.getElementById('activityChart'), {
type: 'line',
data: {
labels,
datasets: [
{
label: 'Registrations',
data: alignData(regResp.data),
borderColor: 'rgb(59, 130, 246)',
tension: 0.1
},
{
label: 'Messages Sent',
data: alignData(msgResp.data),
borderColor: 'rgb(34, 197, 94)',
tension: 0.1
},
{
label: 'Messages Deleted',
data: alignData(delResp.data),
borderColor: 'rgb(239, 68, 68)',
tension: 0.1
},
{
label: 'Attachments Created',
data: alignData(attachResp.data),
borderColor: 'rgb(168, 85, 247)',
tension: 0.1
}
]
},
options: {
responsive: true,
scales: {
y: { beginAtZero: true }
}
}
});
} catch (e) {
console.error('Failed to load chart data:', e);
}
})();
"
}
fn format_number(n: Float) -> String {
let int_val = float.truncate(n)
format_int_with_commas(int_val)
}
fn format_int_with_commas(n: Int) -> String {
let s = int.to_string(n)
let len = string.length(s)
case len {
_ if len <= 3 -> s
_ -> {
let groups = reverse_groups(s, [])
string.join(list.reverse(groups), ",")
}
}
}
fn reverse_groups(s: String, acc: List(String)) -> List(String) {
let len = string.length(s)
case len {
0 -> acc
_ if len <= 3 -> [s, ..acc]
_ -> {
let group = string.slice(s, len - 3, 3)
let rest = string.slice(s, 0, len - 3)
reverse_groups(rest, [group, ..acc])
}
}
}
fn format_timestamp(ts: Int) -> String {
let secs = ts / 1000
let mins = secs / 60
let hours = mins / 60
let days = hours / 24
case days {
0 -> int.to_string(hours) <> "h ago"
1 -> "1 day ago"
_ -> int.to_string(days) <> " days ago"
}
}
fn format_bytes(bytes: Float) -> String {
case bytes {
_ if bytes <. 1024.0 -> format_number(bytes) <> " B"
_ if bytes <. 1_048_576.0 -> {
let kb = bytes /. 1024.0
float_to_string_rounded(kb, 2) <> " KB"
}
_ if bytes <. 1_073_741_824.0 -> {
let mb = bytes /. 1_048_576.0
float_to_string_rounded(mb, 2) <> " MB"
}
_ -> {
let gb = bytes /. 1_073_741_824.0
float_to_string_rounded(gb, 2) <> " GB"
}
}
}
fn float_to_string_rounded(value: Float, decimals: Int) -> String {
let multiplier = case decimals {
0 -> 1.0
1 -> 10.0
2 -> 100.0
3 -> 1000.0
_ -> 100.0
}
let rounded = float.round(value *. multiplier) |> int.to_float
let result = rounded /. multiplier
case decimals {
0 -> {
let int_value = float.round(result)
int.to_string(int_value)
}
_ -> {
let str = float.to_string(result)
case string.contains(str, ".") {
True -> str
False -> str <> ".0"
}
}
}
}
fn format_percentage(value: Float) -> String {
int.to_string(float.truncate(value)) <> "%"
}

View File

@@ -0,0 +1,235 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/constants
import fluxer_admin/log
import fluxer_admin/oauth2
import fluxer_admin/session
import fluxer_admin/web.{type Context, prepend_base_path}
import gleam/dynamic/decode
import gleam/http
import gleam/http/request
import gleam/httpc
import gleam/json
import gleam/list
import gleam/option
import gleam/string
import gleam/uri
import wisp.{type Request, type Response}
pub fn handle(req: Request, ctx: Context) -> Response {
let query = wisp.get_query(req)
let code = list.key_find(query, "code") |> option.from_result
let state = list.key_find(query, "state") |> option.from_result
let error = list.key_find(query, "error") |> option.from_result
let stored_state = case wisp.get_cookie(req, "oauth_state", wisp.Signed) {
Ok(cookie) -> option.Some(cookie)
Error(_) -> option.None
}
case error {
option.Some(err) -> {
log.error("OAuth2 callback error: " <> err)
wisp.redirect(prepend_base_path(ctx, "/login?error=oauth_failed"))
|> wisp.set_cookie(req, "session", "", wisp.Signed, 0)
|> wisp.set_cookie(req, "oauth_state", "", wisp.Signed, 0)
}
option.None ->
case code, state, stored_state {
option.Some(code), option.Some(s), option.Some(stored) if s == stored -> {
let token_url = ctx.api_endpoint <> "/oauth2/token"
log.debug("OAuth2 callback: code received; building token request")
let base_params = [
#("grant_type", "authorization_code"),
#("code", code),
#("redirect_uri", ctx.oauth_redirect_uri),
]
let body =
base_params
|> list.map(fn(p) { p.0 <> "=" <> uri.percent_encode(p.1) })
|> string.join("&")
let assert Ok(req1) = request.to(token_url)
let basic_auth =
"Basic "
<> oauth2.base64_encode_string(
ctx.oauth_client_id <> ":" <> ctx.oauth_client_secret,
)
log.debug("Posting /oauth2/token with Basic auth and form body")
let req1 =
req1
|> request.set_method(http.Post)
|> request.set_header(
"content-type",
"application/x-www-form-urlencoded",
)
|> request.set_header("authorization", basic_auth)
|> request.set_body(body)
case httpc.send(req1) {
Ok(token_resp) if token_resp.status == 200 -> {
log.debug("[oauth2_callback] Token response 200 OK")
let token_decoder = {
use access_token <- decode.field("access_token", decode.string)
use _refresh_token <- decode.optional_field(
"refresh_token",
option.None,
decode.optional(decode.string),
)
decode.success(access_token)
}
case json.parse(token_resp.body, token_decoder) {
Ok(access_token) -> {
log.debug(
"[oauth2_callback] Parsed access_token successfully",
)
let info_url = ctx.api_endpoint <> "/users/@me"
let assert Ok(req2) = request.to(info_url)
let req2 =
req2
|> request.set_method(http.Get)
|> request.set_header(
"authorization",
"Bearer " <> access_token,
)
case httpc.send(req2) {
Ok(info_resp) if info_resp.status == 200 -> {
log.debug("[oauth2_callback] Userinfo response 200 OK")
let info_decoder = {
use id <- decode.field("id", decode.string)
use acls <- decode.optional_field(
"acls",
[],
decode.list(decode.string),
)
decode.success(#(id, acls))
}
case json.parse(info_resp.body, info_decoder) {
Ok(#(user_id, acls)) -> {
log.debug(
"[oauth2_callback] Parsed user_id: "
<> user_id
<> " acls=["
<> string.join(acls, ",")
<> "]",
)
let has_admin_acl =
list.contains(acls, constants.acl_authenticate)
|| list.contains(acls, constants.acl_wildcard)
case has_admin_acl {
True ->
case session.create(ctx, user_id, access_token) {
Ok(cookie) -> {
let redirect_url =
prepend_base_path(ctx, "/dashboard")
log.debug(
"[oauth2_callback] Session created, redirecting to: "
<> redirect_url,
)
wisp.redirect(redirect_url)
|> wisp.set_cookie(
req,
"session",
cookie,
wisp.Signed,
60 * 60 * 24 * 7,
)
|> wisp.set_cookie(
req,
"oauth_state",
"",
wisp.Signed,
0,
)
}
Error(_) -> {
log.error(
"[oauth2_callback] Failed to create session!",
)
wisp.redirect(prepend_base_path(ctx, "/login"))
}
}
False -> {
log.error(
"[oauth2_callback] User missing admin ACLs",
)
wisp.redirect(prepend_base_path(
ctx,
"/login?error=missing_admin_acl",
))
|> wisp.set_cookie(
req,
"oauth_state",
"",
wisp.Signed,
0,
)
}
}
}
Error(_) -> {
log.error(
"[oauth2_callback] Failed to parse users/@me response",
)
wisp.redirect(prepend_base_path(ctx, "/login"))
|> wisp.set_cookie(
req,
"oauth_state",
"",
wisp.Signed,
0,
)
}
}
}
_ -> {
log.error(
"[oauth2_callback] Userinfo request failed or non-200",
)
wisp.redirect(prepend_base_path(ctx, "/login"))
|> wisp.set_cookie(req, "oauth_state", "", wisp.Signed, 0)
}
}
}
Error(_) -> {
log.error("[oauth2_callback] Failed to parse token response")
wisp.redirect(prepend_base_path(ctx, "/login"))
|> wisp.set_cookie(req, "oauth_state", "", wisp.Signed, 0)
}
}
}
_ -> {
log.error("[oauth2_callback] Token request failed or non-200")
wisp.redirect(prepend_base_path(ctx, "/login"))
|> wisp.set_cookie(req, "session", "", wisp.Signed, 0)
|> wisp.set_cookie(req, "oauth_state", "", wisp.Signed, 0)
}
}
}
_, _, _ -> {
log.error("[oauth2_callback] State mismatch or missing code/state")
wisp.redirect(prepend_base_path(ctx, "/login?error=oauth_failed"))
|> wisp.set_cookie(req, "session", "", wisp.Signed, 0)
|> wisp.set_cookie(req, "oauth_state", "", wisp.Signed, 0)
}
}
}
}

View File

@@ -0,0 +1,785 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/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/user
import fluxer_admin/web.{
type Context, type Session, action, href, prepend_base_path,
}
import gleam/int
import gleam/list
import gleam/option
import gleam/string
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Request, type Response}
const suspicious_user_agent_keywords = [
"curl",
"bot",
"spider",
"python",
"java",
"wget",
"httpclient",
"go-http-client",
]
pub fn view(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
) -> Response {
let limit = 50
let result = verifications.list_pending_verifications(ctx, session, limit)
let content = case result {
Ok(response) -> {
let total = list.length(response.pending_verifications)
h.div(
[
a.class("max-w-7xl mx-auto"),
a.id("pending-verifications"),
],
[
ui.flex_row_between([
ui.heading_page("Pending Verifications"),
h.div([a.class("flex items-center gap-4")], [
case total {
0 -> element.none()
count ->
h.span(
[
a.class("body-sm text-neutral-600"),
a.attribute("data-review-progress", ""),
],
[
element.text(int.to_string(count) <> " remaining"),
],
)
},
]),
]),
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"),
),
],
),
]),
),
)
},
],
)
}
Error(err) -> error_view(err)
}
let html =
layout.page(
"Pending Verifications",
"pending-verifications",
ctx,
session,
current_admin,
flash_data,
content,
)
wisp.html_response(element.to_document_string(html), 200)
}
pub fn view_fragment(ctx: Context, session: Session, page: Int) -> Response {
let limit = 50
let _offset = page * limit
let result = verifications.list_pending_verifications(ctx, session, limit)
case result {
Ok(response) -> {
let fragment =
h.div(
[
a.attribute("data-review-fragment", "true"),
a.attribute("data-page", int.to_string(page)),
],
list.map(response.pending_verifications, fn(pv) {
render_pending_verification_card(ctx, pv)
}),
)
wisp.html_response(element.to_document_string(fragment), 200)
}
Error(_) -> {
let empty = h.div([a.attribute("data-review-fragment", "true")], [])
wisp.html_response(element.to_document_string(empty), 200)
}
}
}
pub fn view_single(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
user_id: String,
) -> Response {
let limit = 50
let result = verifications.list_pending_verifications(ctx, session, limit)
let content = case result {
Ok(response) -> {
case
list.find(response.pending_verifications, fn(pv) {
pv.user_id == user_id
})
{
Ok(pv) -> {
h.div(
[
a.class("max-w-7xl mx-auto"),
a.id("pending-verifications"),
],
[
ui.flex_row_between([
ui.heading_page("Pending Verifications"),
h.a(
[
href(ctx, "/pending-verifications"),
a.class("text-sm text-neutral-600 hover:text-neutral-900"),
],
[element.text("Back to all")],
),
]),
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"),
),
],
),
]),
),
),
],
)
}
Error(_) -> {
h.div([a.class("max-w-7xl mx-auto")], [
h.div([a.class("bg-red-50 border border-red-200 rounded-lg p-8")], [
h.div([a.class("text-sm text-red-900")], [
element.text("Verification not found"),
]),
]),
])
}
}
}
Error(err) -> error_view(err)
}
let html =
layout.page(
"Pending Verification",
"pending-verifications",
ctx,
session,
current_admin,
flash_data,
content,
)
wisp.html_response(element.to_document_string(html), 200)
}
pub fn handle_action(
req: Request,
ctx: Context,
session: Session,
action: option.Option(String),
background: Bool,
) -> Response {
use form_data <- wisp.require_form(req)
let user_id = list.key_find(form_data.values, "user_id") |> option.from_result
case action {
option.Some("approve") ->
handle_pending_verification_approval(ctx, session, user_id, background)
option.Some("reject") ->
handle_pending_verification_rejection(ctx, session, user_id, background)
_ ->
case background {
True -> wisp.json_response("{\"error\": \"Unknown action\"}", 400)
False ->
flash.redirect_with_error(
ctx,
"/pending-verifications",
"Unknown action",
)
}
}
}
fn handle_pending_verification_approval(
ctx: Context,
session: Session,
user_id: option.Option(String),
background: Bool,
) -> Response {
case user_id {
option.Some(id) -> {
case verifications.approve_registration(ctx, session, id) {
Ok(_) ->
case background {
True -> wisp.json_response("{}", 204)
False ->
flash.redirect_with_success(
ctx,
"/pending-verifications",
"Approved registration for " <> id,
)
}
Error(err) ->
case background {
True ->
wisp.json_response(
"{\"error\": \"" <> api_error_message(err) <> "\"}",
400,
)
False ->
flash.redirect_with_error(
ctx,
"/pending-verifications",
api_error_message(err),
)
}
}
}
option.None ->
case background {
True -> wisp.json_response("{\"error\": \"Missing user_id\"}", 400)
False ->
flash.redirect_with_error(
ctx,
"/pending-verifications",
"Missing user_id",
)
}
}
}
fn handle_pending_verification_rejection(
ctx: Context,
session: Session,
user_id: option.Option(String),
background: Bool,
) -> Response {
case user_id {
option.Some(id) -> {
case verifications.reject_registration(ctx, session, id) {
Ok(_) ->
case background {
True -> wisp.json_response("{}", 204)
False ->
flash.redirect_with_success(
ctx,
"/pending-verifications",
"Rejected registration for " <> id,
)
}
Error(err) ->
case background {
True ->
wisp.json_response(
"{\"error\": \"" <> api_error_message(err) <> "\"}",
400,
)
False ->
flash.redirect_with_error(
ctx,
"/pending-verifications",
api_error_message(err),
)
}
}
}
option.None ->
case background {
True -> wisp.json_response("{\"error\": \"Missing user_id\"}", 400)
False ->
flash.redirect_with_error(
ctx,
"/pending-verifications",
"Missing user_id",
)
}
}
}
fn api_error_message(err: common.ApiError) -> String {
case err {
common.Unauthorized -> "Unauthorized"
common.Forbidden(message) -> message
common.NotFound -> "Not Found"
common.NetworkError -> "Network error"
common.ServerError -> "Server error"
}
}
fn render_pending_verification_card(
ctx: Context,
pv: verifications.PendingVerification,
) -> element.Element(a) {
let metadata_warning = user_agent_warning(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),
],
[
h.div([a.class("flex items-start gap-4 mb-6")], [
h.img([
a.src(avatar.get_user_avatar_url(
ctx.media_endpoint,
ctx.cdn_endpoint,
pv.user.id,
pv.user.avatar,
True,
ctx.asset_version,
)),
a.alt(pv.user.username),
a.class("w-16 h-16 rounded-full flex-shrink-0"),
]),
h.div([a.class("flex-1 min-w-0")], [
h.a(
[
href(ctx, "/users/" <> pv.user.id),
a.class(
"text-lg text-neutral-900 hover:text-neutral-600 underline decoration-neutral-300 hover:decoration-neutral-500 font-semibold",
),
],
[
element.text(
pv.user.username
<> "#"
<> user.format_discriminator(pv.user.discriminator),
),
],
),
h.div([a.class("text-sm text-neutral-600 mt-1 truncate")], [
case pv.user.email {
option.Some(email) -> element.text(email)
option.None -> element.text("N/A")
},
]),
h.div([a.class("text-sm text-neutral-500 mt-1")], [
element.text("Registered " <> format_timestamp(pv.created_at)),
]),
]),
]),
h.details(
[
a.class("group mb-6"),
a.attribute("open", ""),
],
[
h.summary(
[
a.class(
"cursor-pointer text-sm text-neutral-600 hover:text-neutral-900 list-none flex items-center gap-2",
),
],
[
element.text("Registration metadata"),
h.span([a.class("text-neutral-400 group-open:hidden")], [
element.text(""),
]),
h.span([a.class("text-neutral-400 hidden group-open:inline")], [
element.text(""),
]),
],
),
h.div([a.class("mt-3 text-sm text-neutral-600 space-y-1")], [
render_registration_metadata(pv.metadata, metadata_warning),
]),
],
),
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",
),
],
[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",
),
],
[element.text("Approve")],
),
],
),
],
)
}
fn render_registration_metadata(
metadata: List(verifications.PendingVerificationMetadata),
warning: option.Option(String),
) -> element.Element(a) {
let ip = option_or_default("Unknown", metadata_value(metadata, "ip_address"))
let normalized_ip =
option_or_default(ip, metadata_value(metadata, "normalized_ip"))
let ip_display = case normalized_ip == ip {
True -> ip
False -> ip <> " (Normalized: " <> normalized_ip <> ")"
}
let geoip_reason =
option_or_default("none", metadata_value(metadata, "geoip_reason"))
let os = option_or_default("Unknown", metadata_value(metadata, "os"))
let browser =
option_or_default("Unknown", metadata_value(metadata, "browser"))
let device = option_or_default("Unknown", metadata_value(metadata, "device"))
let display_name =
option_or_default("N/A", metadata_value(metadata, "display_name"))
let user_agent =
option_or_default("Not provided", metadata_value(metadata, "user_agent"))
let location =
option_or_default("Unknown Location", metadata_value(metadata, "location"))
let ip_reverse = metadata_value(metadata, "ip_address_reverse")
let geoip_note = case geoip_reason {
"none" -> element.none()
reason ->
h.div([a.class("text-xs text-neutral-500")], [
element.text("GeoIP hint: " <> reason),
])
}
h.div([a.class("flex flex-col gap-0.5 text-xs text-neutral-600")], [
h.div([], [element.text("Display Name: " <> display_name)]),
h.div([], [element.text("IP: " <> ip_display)]),
h.div([], [element.text("Location: " <> location)]),
case ip_reverse {
option.Some(reverse) ->
h.div([], [element.text("Reverse DNS: " <> reverse)])
option.None -> element.none()
},
geoip_note,
h.div([], [element.text("OS: " <> os)]),
h.div([], [element.text("Browser: " <> browser)]),
h.div([], [element.text("Device: " <> device)]),
h.div([a.class("break-words")], [element.text("User Agent: " <> user_agent)]),
render_user_agent_warning(warning),
])
}
fn render_user_agent_warning(
warning: option.Option(String),
) -> element.Element(a) {
case warning {
option.Some(message) ->
h.div([a.class("mt-2")], [
ui.pill(message, ui.PillWarning),
])
option.None -> element.none()
}
}
fn user_agent_warning(
metadata: List(verifications.PendingVerificationMetadata),
) -> option.Option(String) {
let user_agent = option_or_default("", metadata_value(metadata, "user_agent"))
let normalized = user_agent |> string.trim |> string.lowercase
case string.is_empty(normalized) {
True -> option.Some("Missing user agent")
False ->
case find_suspicious_keyword(normalized, suspicious_user_agent_keywords) {
option.Some(keyword) ->
option.Some("Suspicious user agent (" <> keyword <> ")")
option.None -> option.None
}
}
}
fn metadata_value(
metadata: List(verifications.PendingVerificationMetadata),
key: String,
) -> option.Option(String) {
list.fold(metadata, option.None, fn(acc, entry) {
case acc {
option.Some(_) -> acc
option.None ->
case entry {
verifications.PendingVerificationMetadata(
key: entry_key,
value: entry_value,
) ->
case entry_key == key {
True -> option.Some(entry_value)
False -> option.None
}
}
}
})
}
fn option_or_default(default: String, value: option.Option(String)) -> String {
case value {
option.Some(v) -> v
option.None -> default
}
}
fn find_suspicious_keyword(
normalized: String,
keywords: List(String),
) -> option.Option(String) {
list.fold(keywords, option.None, fn(acc, keyword) {
case acc {
option.Some(_) -> acc
option.None ->
case string.contains(normalized, keyword) {
True -> option.Some(keyword)
False -> option.None
}
}
})
}
fn format_timestamp(timestamp: String) -> String {
case string.split(timestamp, "T") {
[date_part, time_part] -> {
let time_clean = case string.split(time_part, ".") {
[hms, _] -> hms
_ -> time_part
}
let time_clean = string.replace(time_clean, "Z", "")
case string.split(time_clean, ":") {
[hour, minute, _] -> date_part <> " " <> hour <> ":" <> minute
_ -> timestamp
}
}
_ -> timestamp
}
}
fn empty_state() {
ui.card_empty([
ui.text_muted("No pending verifications"),
ui.text_small_muted("All registration requests have been processed"),
])
}
fn error_view(err: common.ApiError) {
let #(title, message) = case err {
common.Unauthorized -> #(
"Authentication Required",
"Your session has expired. Please log in again.",
)
common.Forbidden(msg) -> #("Permission Denied", msg)
common.NotFound -> #(
"Not Found",
"Pending verifications could not be retrieved.",
)
common.ServerError -> #(
"Server Error",
"An internal server error occurred. Please try again later.",
)
common.NetworkError -> #(
"Network Error",
"Could not connect to the API. Please try again later.",
)
}
h.div([a.class("max-w-4xl mx-auto")], [
h.div([a.class("bg-red-50 border border-red-200 rounded-lg p-8")], [
h.div([a.class("flex items-start gap-4")], [
h.div(
[
a.class(
"flex-shrink-0 w-12 h-12 bg-red-100 rounded-full flex items-center justify-center",
),
],
[
h.span([a.class("text-red-600 title-sm")], [
element.text("!"),
]),
],
),
h.div([a.class("flex-1")], [
h.h2([a.class("title-sm text-red-900 mb-2")], [
element.text(title),
]),
h.p([a.class("text-red-700")], [element.text(message)]),
]),
]),
]),
])
}

View File

@@ -0,0 +1,53 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common
import fluxer_admin/components/flash
import fluxer_admin/pages/ban_management_page
import fluxer_admin/web.{type Context, type Session}
import gleam/option
import wisp.{type Request, type Response}
pub fn view(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
) -> Response {
ban_management_page.view(
ctx,
session,
current_admin,
flash_data,
ban_management_page.PhoneBan,
)
}
pub fn handle_action(
req: Request,
ctx: Context,
session: Session,
action: option.Option(String),
) -> Response {
ban_management_page.handle_action(
req,
ctx,
session,
ban_management_page.PhoneBan,
action,
)
}

View File

@@ -0,0 +1,657 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common
import fluxer_admin/api/reports
import fluxer_admin/components/flash
import fluxer_admin/components/layout
import fluxer_admin/components/message_list
import fluxer_admin/components/ui
import fluxer_admin/web.{type Context, type Session, href}
import gleam/list
import gleam/option
import gleam/string
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Response}
pub fn view(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
report_id: String,
) -> Response {
let result = reports.get_report_detail(ctx, session, report_id)
let #(content, include_script) = case result {
Ok(report) -> {
let has_message_context =
report.report_type == 0 && !list.is_empty(report.message_context)
#(
h.div([a.class("max-w-5xl mx-auto")], [
h.div([a.class("mb-6")], [
ui.flex_row("4", [
h.a(
[
href(ctx, "/reports"),
a.class(
"px-3 py-2 bg-white text-neutral-700 border border-neutral-300 rounded-lg label hover:bg-neutral-50 transition-colors",
),
],
[element.text("← Back to Reports")],
),
ui.heading_page("Report Details"),
]),
]),
h.div([a.class("grid grid-cols-1 lg:grid-cols-3 gap-6")], [
h.div([a.class("lg:col-span-2 space-y-6")], [
render_basic_info(ctx, report),
render_reported_entity(ctx, report),
render_message_context(ctx, report),
case report.additional_info {
option.Some(info) -> render_additional_info(info)
option.None -> element.none()
},
]),
h.div([a.class("space-y-6")], [
render_status_card(ctx, report),
render_actions_card(ctx, report),
]),
]),
]),
has_message_context,
)
}
Error(err) -> #(error_view(err), False)
}
let html =
layout.page(
"Report Details",
"reports",
ctx,
session,
current_admin,
flash_data,
content,
)
let html_string = element.to_document_string(html)
let final_html = case include_script {
True ->
string.replace(
html_string,
"</body>",
message_list.deletion_script() <> "</body>",
)
False -> html_string
}
wisp.html_response(final_html, 200)
}
pub fn fragment(ctx: Context, session: Session, report_id: String) -> Response {
let result = reports.get_report_detail(ctx, session, report_id)
let content = case result {
Ok(report) ->
h.div([a.attribute("data-report-fragment", ""), a.class("space-y-4")], [
render_basic_info(ctx, report),
render_reported_entity(ctx, report),
render_message_context(ctx, report),
case report.additional_info {
option.Some(info) -> render_additional_info(info)
option.None -> element.none()
},
])
Error(err) ->
h.div([a.attribute("data-report-fragment", "")], [
h.div(
[
a.class(
"bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-800",
),
],
[
element.text("Failed to load report: " <> api_error_message(err)),
],
),
])
}
wisp.html_response(element.to_document_string(content), 200)
}
fn api_error_message(err: common.ApiError) -> String {
case err {
common.Unauthorized -> "Unauthorized"
common.Forbidden(message) -> message
common.NotFound -> "Not Found"
common.NetworkError -> "Network error"
common.ServerError -> "Server error"
}
}
fn render_basic_info(ctx: Context, report: reports.Report) {
let reporter_primary = case report.reporter_tag {
option.Some(tag) -> tag
option.None ->
case report.reporter_email {
option.Some(email) -> email
option.None -> "Anonymous"
}
}
let reporter_row = case report.reporter_id {
option.Some(id) ->
render_info_row_with_link(
ctx,
"Reporter",
reporter_primary,
"/users/" <> id,
True,
)
option.None -> render_info_row("Reporter", reporter_primary, False)
}
h.div([a.class("bg-white border border-neutral-200 rounded-lg p-6")], [
h.h2([a.class("title-sm text-neutral-900 mb-4")], [
element.text("Basic Information"),
]),
h.dl([a.class("grid grid-cols-1 sm:grid-cols-2 gap-4")], [
render_info_row("Report ID", report.report_id, True),
render_info_row(
"Reported At",
format_timestamp(report.reported_at),
False,
),
render_info_row("Type", format_report_type(report.report_type), False),
render_info_row("Category", report.category, False),
reporter_row,
render_info_row_opt("Reporter Email", report.reporter_email, False),
render_info_row_opt(
"Full Legal Name",
report.reporter_full_legal_name,
False,
),
render_info_row_opt(
"Country of Residence",
report.reporter_country_of_residence,
False,
),
render_info_row(
"Status",
case report.status {
0 -> "Pending"
1 -> "Resolved"
_ -> "Unknown"
},
False,
),
]),
])
}
fn render_reported_entity(ctx: Context, report: reports.Report) {
h.div([a.class("bg-white border border-neutral-200 rounded-lg p-6")], [
h.h2([a.class("title-sm text-neutral-900 mb-4")], [
element.text("Reported Entity"),
]),
h.dl([a.class("grid grid-cols-1 gap-4")], case report.report_type {
0 -> [
render_info_row_opt_with_link(
ctx,
"User",
report.reported_user_id,
option.Some(format_reported_user_label(report)),
fn(id) { "/users/" <> id },
True,
),
render_info_row_opt("Message ID", report.reported_message_id, True),
render_info_row_opt("Channel ID", report.reported_channel_id, True),
render_info_row_opt("Channel Name", report.reported_channel_name, False),
render_info_row_opt(
"Guild Invite Code",
report.reported_guild_invite_code,
False,
),
]
1 -> [
render_info_row_opt_with_link(
ctx,
"User",
report.reported_user_id,
option.Some(format_reported_user_label(report)),
fn(id) { "/users/" <> id },
True,
),
render_info_row_opt("Guild Name", report.reported_guild_name, False),
render_info_row_opt("Guild ID", report.reported_guild_id, True),
render_info_row_opt(
"Guild Invite Code",
report.reported_guild_invite_code,
False,
),
]
2 -> [
render_info_row_opt_with_link(
ctx,
"Guild",
report.reported_guild_id,
report.reported_guild_name,
fn(id) { "/guilds/" <> id },
True,
),
render_info_row_opt(
"Guild Invite Code",
report.reported_guild_invite_code,
False,
),
]
_ -> [element.text("Unknown report type")]
}),
])
}
fn render_message_context(ctx: Context, report: reports.Report) {
case report.report_type {
0 -> {
case list.is_empty(report.message_context) {
True -> element.none()
False ->
h.div([a.class("bg-white border border-neutral-200 rounded-lg p-6")], [
h.h2([a.class("title-sm text-neutral-900 mb-4")], [
element.text("Message Context"),
]),
message_list.render(ctx, report.message_context, True),
])
}
}
_ -> element.none()
}
}
fn render_additional_info(info: String) {
h.div([a.class("bg-white border border-neutral-200 rounded-lg p-6")], [
h.h2([a.class("title-sm text-neutral-900 mb-4")], [
element.text("Additional Information"),
]),
h.p([a.class("text-neutral-700 whitespace-pre-wrap")], [element.text(info)]),
])
}
fn render_status_card(ctx: Context, report: reports.Report) {
h.div([a.class("bg-white border border-neutral-200 rounded-lg p-6")], [
h.h2([a.class("title-sm text-neutral-900 mb-4")], [
element.text("Status"),
]),
h.div([a.class("space-y-3")], [
h.div([a.class("text-center")], [
case report.status {
0 ->
h.span(
[
a.class(
"px-4 py-2 subtitle rounded-lg bg-yellow-100 text-yellow-700",
),
],
[element.text("Pending")],
)
1 ->
h.span(
[
a.class(
"px-4 py-2 subtitle rounded-lg bg-green-100 text-green-700",
),
],
[element.text("Resolved")],
)
_ ->
h.span(
[
a.class(
"px-4 py-2 subtitle rounded-lg bg-neutral-100 text-neutral-700",
),
],
[element.text("Unknown")],
)
},
]),
case report.resolved_at {
option.Some(timestamp) ->
h.div([a.class("body-sm text-neutral-600")], [
h.span([a.class("label")], [element.text("Resolved At: ")]),
element.text(format_timestamp(timestamp)),
])
option.None -> element.none()
},
case report.resolved_by_admin_id {
option.Some(admin_id) ->
h.div([a.class("body-sm text-neutral-600")], [
h.span([a.class("label")], [element.text("Resolved By: ")]),
h.a(
[
href(ctx, "/users/" <> admin_id),
a.class("underline hover:text-neutral-900"),
],
[element.text(admin_id)],
),
])
option.None -> element.none()
},
case report.public_comment {
option.Some(comment) ->
h.div([a.class("pt-3 border-t border-neutral-200")], [
h.p([a.class("body-sm text-neutral-700 mb-2")], [
element.text("Public Comment:"),
]),
h.p([a.class("body-sm text-neutral-600 whitespace-pre-wrap")], [
element.text(comment),
]),
])
option.None -> element.none()
},
]),
])
}
fn render_actions_card(ctx: Context, report: reports.Report) {
h.div([a.class("bg-white border border-neutral-200 rounded-lg p-6")], [
h.h2([a.class("title-sm text-neutral-900 mb-4")], [
element.text("Actions"),
]),
h.div([a.class("space-y-3")], [
case report.status {
0 ->
h.button(
[
a.class(
"w-full px-4 py-2 bg-neutral-900 text-white rounded-lg label hover:bg-neutral-800 transition-colors",
),
a.attribute(
"onclick",
"if(confirm('Resolve this report?')) { fetch('/reports/"
<> report.report_id
<> "/resolve', { method: 'POST', headers: { 'Content-Type': 'application/json' } }).then(() => location.reload()) }",
),
],
[element.text("Resolve Report")],
)
_ -> element.none()
},
case report.report_type {
0 -> {
case report.reported_user_id {
option.Some(user_id) ->
h.a(
[
href(ctx, "/users/" <> user_id),
a.class(
"block w-full px-4 py-2 bg-white text-neutral-700 border border-neutral-300 rounded-lg label hover:bg-neutral-50 transition-colors text-center",
),
],
[element.text("View Reported User")],
)
option.None -> element.none()
}
}
1 -> {
case report.reported_user_id {
option.Some(user_id) ->
h.a(
[
href(ctx, "/users/" <> user_id),
a.class(
"block w-full px-4 py-2 bg-white text-neutral-700 border border-neutral-300 rounded-lg label hover:bg-neutral-50 transition-colors text-center",
),
],
[element.text("View Reported User")],
)
option.None -> element.none()
}
}
2 -> {
case report.reported_guild_id {
option.Some(guild_id) ->
h.a(
[
href(ctx, "/guilds/" <> guild_id),
a.class(
"block w-full px-4 py-2 bg-white text-neutral-700 border border-neutral-300 rounded-lg label hover:bg-neutral-50 transition-colors text-center",
),
],
[element.text("View Reported Guild")],
)
option.None -> element.none()
}
}
_ -> element.none()
},
case report.reporter_id {
option.Some(user_id) ->
h.a(
[
href(ctx, "/users/" <> user_id),
a.class(
"block w-full px-4 py-2 bg-white text-neutral-700 border border-neutral-300 rounded-lg label hover:bg-neutral-50 transition-colors text-center",
),
],
[element.text("View Reporter")],
)
option.None -> element.none()
},
]),
])
}
fn render_info_row(label: String, value: String, mono: Bool) {
h.div([], [
h.dt([a.class("body-sm text-neutral-500 mb-1")], [
element.text(label),
]),
h.dd(
[
a.class(case mono {
True -> "body-sm text-neutral-900"
False -> "body-sm text-neutral-900"
}),
],
[element.text(value)],
),
])
}
fn render_info_row_with_link(
ctx: Context,
label: String,
value: String,
path: String,
mono: Bool,
) {
h.div([], [
h.dt([a.class("body-sm text-neutral-500 mb-1")], [
element.text(label),
]),
h.dd([], [
h.a(
[
href(ctx, path),
a.class(
"body-sm text-neutral-900 hover:text-neutral-600 underline decoration-neutral-300 hover:decoration-neutral-500 "
<> case mono {
True -> ""
False -> ""
},
),
],
[element.text(value)],
),
]),
])
}
fn render_info_row_opt(label: String, value: option.Option(String), mono: Bool) {
h.div([], [
h.dt([a.class("body-sm text-neutral-500 mb-1")], [
element.text(label),
]),
h.dd(
[
a.class(case mono {
True -> "body-sm text-neutral-900"
False -> "body-sm text-neutral-900"
}),
],
[
element.text(case value {
option.Some(v) -> v
option.None -> ""
}),
],
),
])
}
fn render_info_row_opt_with_link(
ctx: Context,
label: String,
id: option.Option(String),
name: option.Option(String),
path_fn: fn(String) -> String,
mono: Bool,
) {
h.div([], [
h.dt([a.class("body-sm text-neutral-500 mb-1")], [
element.text(label),
]),
h.dd([], [
case id {
option.Some(id_val) -> {
let display = case name {
option.Some(n) -> n
option.None -> id_val
}
h.a(
[
href(ctx, path_fn(id_val)),
a.class(
"body-sm text-neutral-900 hover:text-neutral-600 underline decoration-neutral-300 hover:decoration-neutral-500 "
<> case mono {
True -> ""
False -> ""
},
),
],
[element.text(display)],
)
}
option.None ->
h.span([a.class("body-sm text-neutral-400 italic")], [
element.text(""),
])
},
]),
])
}
fn format_timestamp(timestamp: String) -> String {
case string.split(timestamp, "T") {
[date_part, time_part] -> {
let time_clean = case string.split(time_part, ".") {
[hms, _] -> hms
_ -> time_part
}
let time_clean = string.replace(time_clean, "Z", "")
case string.split(time_clean, ":") {
[hour, minute, _] -> date_part <> " " <> hour <> ":" <> minute
_ -> timestamp
}
}
_ -> timestamp
}
}
fn format_report_type(report_type: Int) -> String {
case report_type {
0 -> "Message"
1 -> "User"
2 -> "Guild"
_ -> "Unknown"
}
}
fn format_reported_user_label(report: reports.Report) -> String {
case report.reported_user_tag {
option.Some(tag) -> tag
option.None ->
case report.reported_user_username {
option.Some(username) -> {
let discriminator =
option.unwrap(report.reported_user_discriminator, "0000")
username <> "#" <> discriminator
}
option.None ->
"User " <> option.unwrap(report.reported_user_id, "unknown")
}
}
}
fn error_view(err: common.ApiError) {
let #(title, message) = case err {
common.Unauthorized -> #(
"Authentication Required",
"Your session has expired. Please log in again.",
)
common.Forbidden(msg) -> #("Permission Denied", msg)
common.NotFound -> #("Not Found", "Report not found.")
common.ServerError -> #(
"Server Error",
"An internal server error occurred. Please try again later.",
)
common.NetworkError -> #(
"Network Error",
"Could not connect to the API. Please try again later.",
)
}
h.div([a.class("max-w-4xl mx-auto")], [
h.div([a.class("bg-red-50 border border-red-200 rounded-lg p-8")], [
h.div([a.class("flex items-start gap-4")], [
h.div(
[
a.class(
"flex-shrink-0 w-12 h-12 bg-red-100 rounded-full flex items-center justify-center",
),
],
[
h.span([a.class("text-red-600 title-sm")], [
element.text("!"),
]),
],
),
h.div([a.class("flex-1")], [
h.h2([a.class("title-sm text-red-900 mb-2")], [
element.text(title),
]),
h.p([a.class("text-red-700")], [element.text(message)]),
]),
]),
]),
])
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,313 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common
import fluxer_admin/api/search
import fluxer_admin/components/flash
import fluxer_admin/components/layout
import fluxer_admin/components/ui
import fluxer_admin/web.{type Context, type Session, href}
import gleam/int
import gleam/option
import gleam/string
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Request, type Response}
pub fn view(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
job_id: option.Option(String),
) -> Response {
let should_auto_refresh = case job_id {
option.Some(id) -> {
case search.get_index_refresh_status(ctx, session, id) {
Ok(status) ->
status.status == "in_progress" || status.status == "not_found"
Error(common.NotFound) -> True
Error(_) -> False
}
}
option.None -> False
}
let content =
h.div([a.class("max-w-3xl mx-auto space-y-6")], [
ui.heading_page("Search Index Management"),
render_reindex_controls(ctx),
case job_id {
option.Some(id) -> render_status_section(ctx, session, id)
option.None -> element.none()
},
])
let html =
layout.page_with_refresh(
"Search Management",
"search-index",
ctx,
session,
current_admin,
flash_data,
content,
should_auto_refresh,
)
wisp.html_response(element.to_document_string(html), 200)
}
pub fn handle_reindex(
_req: Request,
ctx: Context,
session: Session,
index_type: option.Option(String),
) -> Response {
case index_type {
option.Some(idx_type) -> {
case search.refresh_search_index(ctx, session, idx_type, option.None) {
Ok(response) ->
wisp.redirect(web.prepend_base_path(
ctx,
"/search-index?job_id=" <> response.job_id,
))
Error(_) ->
wisp.redirect(web.prepend_base_path(ctx, "/search-index?error=failed"))
}
}
option.None -> wisp.redirect(web.prepend_base_path(ctx, "/search-index"))
}
}
fn render_reindex_controls(ctx: Context) {
h.div([a.class("space-y-3")], [
h.h3([a.class("subtitle text-neutral-900")], [
element.text("Global Search Indexes"),
]),
render_reindex_button(ctx, "Users", "users"),
render_reindex_button(ctx, "Guilds", "guilds"),
render_reindex_button(ctx, "Reports", "reports"),
render_reindex_button(ctx, "Audit Logs", "audit_logs"),
h.h3([a.class("subtitle text-neutral-900 mt-6")], [
element.text("Guild-specific Search Indexes"),
]),
h.p([a.class("body-sm text-neutral-600 mb-3")], [
element.text(
"These indexes require a guild ID and can only be triggered from the guild detail page.",
),
]),
render_disabled_reindex_button(ctx, "Channel Messages", "channel_messages"),
])
}
fn render_reindex_button(ctx: Context, title: String, index_type: String) {
h.form(
[
a.class("flex"),
a.method("post"),
web.action(ctx, "/search-index?action=reindex"),
],
[
h.input([a.type_("hidden"), a.name("index_type"), a.value(index_type)]),
h.button(
[
a.type_("submit"),
a.class(
"w-full px-4 py-3 rounded-lg border border-neutral-300 bg-white text-neutral-900 label hover:bg-neutral-100 transition-colors",
),
],
[element.text("Reindex " <> title)],
),
],
)
}
fn render_disabled_reindex_button(
_ctx: Context,
title: String,
_index_type: String,
) {
h.div([a.class("flex")], [
h.button(
[
a.disabled(True),
a.class(
"w-full px-4 py-3 rounded-lg border border-neutral-300 bg-neutral-100 text-neutral-400 label cursor-not-allowed",
),
],
[element.text("Reindex " <> title)],
),
])
}
fn render_status_section(ctx: Context, session: Session, job_id: String) {
let status_result = search.get_index_refresh_status(ctx, session, job_id)
h.div([a.class("border border-neutral-200 rounded-lg p-4 space-y-3 mt-6")], [
h.div([a.class("flex items-center justify-between")], [
h.h2([a.class("subtitle text-neutral-900")], [
element.text("Reindex progress"),
]),
h.a([href(ctx, "/search-index"), a.class("body-sm text-neutral-600")], [
element.text("Clear"),
]),
]),
case status_result {
Ok(status) -> {
case status.status {
"not_found" ->
h.p([a.class("body-sm text-neutral-700")], [
element.text("Preparing job… check back in a moment."),
])
_ -> render_status_content(ctx, status)
}
}
Error(common.NotFound) ->
h.p([a.class("body-sm text-neutral-700")], [
element.text("Preparing job… check back in a moment."),
])
Error(err) -> render_status_error(ctx, err)
},
])
}
fn render_status_content(_ctx: Context, status: search.IndexRefreshStatus) {
h.div([a.class("space-y-3")], [
h.p([a.class("body-sm text-neutral-700")], [
element.text("Status: " <> format_status_label(status.status)),
]),
case status.status, status.total, status.indexed {
"in_progress", option.Some(total), option.Some(indexed) -> {
let percentage = case total {
0 -> 0
_ -> { indexed * 100 } / total
}
h.div([a.class("space-y-2")], [
h.div([a.class("flex justify-between body-sm text-neutral-700")], [
h.span([], [
element.text(
int.to_string(indexed)
<> " / "
<> int.to_string(total)
<> " ("
<> int.to_string(percentage)
<> "%)",
),
]),
]),
h.div(
[a.class("w-full bg-neutral-200 rounded-full h-2 overflow-hidden")],
[
h.div(
[
a.class("bg-neutral-900 h-2 transition-[width] duration-300"),
a.attribute(
"style",
"width: " <> int.to_string(percentage) <> "%",
),
],
[],
),
],
),
])
}
"completed", option.Some(total), option.Some(indexed) ->
h.p([a.class("body-sm text-neutral-700")], [
element.text(
"Indexed "
<> int.to_string(indexed)
<> " / "
<> int.to_string(total)
<> " items",
),
])
_, _, _ -> element.none()
},
case status.started_at {
option.Some(timestamp) ->
h.p([a.class("caption text-neutral-500")], [
element.text("Started " <> format_timestamp(timestamp)),
])
option.None -> element.none()
},
case status.completed_at {
option.Some(timestamp) ->
h.p([a.class("caption text-neutral-500")], [
element.text("Completed " <> format_timestamp(timestamp)),
])
option.None -> element.none()
},
case status.error {
option.Some(error_msg) ->
h.p([a.class("body-sm text-red-600")], [element.text(error_msg)])
option.None -> element.none()
},
])
}
fn render_status_error(_ctx: Context, err: common.ApiError) {
let #(title, message) = case err {
common.Unauthorized -> #(
"Authentication Required",
"Your session has expired. Please log in again.",
)
common.Forbidden(msg) -> #("Permission Denied", msg)
common.NotFound -> #("Not Found", "Status information not found.")
common.ServerError -> #(
"Server Error",
"An internal server error occurred. Please try again later.",
)
common.NetworkError -> #(
"Network Error",
"Could not connect to the API. Please try again later.",
)
}
h.div([a.class("space-y-1 body-sm text-red-600")], [
h.p([], [element.text(title)]),
h.p([], [element.text(message)]),
])
}
fn format_status_label(status: String) -> String {
case status {
"in_progress" -> "In progress"
"completed" -> "Completed"
"failed" -> "Failed"
_ -> "Unknown"
}
}
fn format_timestamp(timestamp: String) -> String {
case string.split(timestamp, "T") {
[date_part, time_part] -> {
let time_clean = case string.split(time_part, ".") {
[hms, _] -> hms
_ -> time_part
}
let time_clean = string.replace(time_clean, "Z", "")
case string.split(time_clean, ":") {
[hour, minute, _] -> date_part <> " " <> hour <> ":" <> minute
_ -> timestamp
}
}
_ -> timestamp
}
}

View File

@@ -0,0 +1,640 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common
import fluxer_admin/api/metrics
import fluxer_admin/components/flash
import fluxer_admin/components/layout
import fluxer_admin/components/ui
import fluxer_admin/web.{type Context, type Session, prepend_base_path}
import gleam/float
import gleam/int
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/order
import gleam/string
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Response}
pub fn view(
ctx: Context,
session: Session,
current_admin: Option(common.UserLookupResult),
flash_data: Option(flash.Flash),
) -> Response {
let content = case ctx.metrics_endpoint {
None -> render_not_configured()
Some(_) -> render_dashboard(ctx)
}
let html =
layout.page(
"Storage & Infrastructure",
"storage",
ctx,
session,
current_admin,
flash_data,
content,
)
wisp.html_response(element.to_document_string(html), 200)
}
fn render_not_configured() {
ui.stack("6", [
ui.heading_page("Storage & Infrastructure"),
h.div(
[
a.class(
"bg-yellow-50 border border-yellow-200 rounded-lg p-6 text-center",
),
],
[
h.p([a.class("text-yellow-800")], [
element.text(
"Metrics service not configured. Set FLUXER_METRICS_HOST to enable.",
),
]),
],
),
])
}
fn render_dashboard(ctx: Context) {
let attachment_storage =
metrics.query_aggregate(ctx, "attachment.storage.bytes")
let attachments_created = metrics.query_aggregate(ctx, "attachment.created")
let attachments_expired = metrics.query_aggregate(ctx, "attachment.expired")
let content_type_breakdown =
metrics.query_aggregate_grouped(
ctx,
"attachment.created",
option.Some("content_type"),
)
let proxy_endpoint = prepend_base_path(ctx, "/api/metrics")
h.div([], [
ui.heading_page("Storage & Infrastructure"),
h.div([a.class("mt-6")], [
h.div(
[a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")],
[
h.div([a.class("p-6")], [
ui.heading_section("S3/Storage Metrics"),
ui.text_small_muted("Storage usage and attachment activity"),
h.div([a.class("grid grid-cols-2 md:grid-cols-4 gap-4 mt-4")], [
render_storage_card(attachment_storage),
render_stat_card("Attachments Created", attachments_created),
render_stat_card("Attachments Expired", attachments_expired),
render_loading_stat_card(
"Storage Growth (24h)",
"storage-growth-24h",
),
]),
]),
],
),
]),
h.div([a.class("mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6")], [
h.div(
[a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")],
[
h.div([a.class("p-6")], [
ui.heading_section("Storage by Content Type"),
ui.text_small_muted("Breakdown of attachments by MIME type"),
h.div([a.class("mt-4 max-h-64 overflow-y-auto")], [
render_content_type_breakdown(content_type_breakdown),
]),
]),
],
),
h.div(
[a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")],
[
h.div([a.class("p-6")], [
ui.heading_section("Storage Over Time"),
ui.text_small_muted("Cumulative storage bytes over time"),
h.div([a.class("mt-4")], [
element.element(
"canvas",
[a.id("storageChart"), a.attribute("height", "200")],
[],
),
]),
]),
],
),
]),
h.div([a.class("mt-6")], [
h.div(
[a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")],
[
h.div([a.class("p-6")], [
ui.heading_section("CDN & Queue Metrics"),
ui.text_small_muted(
"Cloudflare purge queue and asset deletion queue sizes",
),
h.div([a.class("grid grid-cols-2 md:grid-cols-4 gap-4 mt-4")], [
render_loading_stat_card(
"Cloudflare Purge Queue",
"redis-cloudflare-purge",
),
render_loading_stat_card(
"Asset Deletion Queue",
"redis-asset-deletion",
),
render_loading_stat_card(
"Bulk Message Deletion",
"redis-bulk-message-deletion",
),
render_loading_stat_card(
"Account Deletion Queue",
"redis-account-deletion",
),
]),
]),
],
),
]),
h.div([a.class("mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6")], [
h.div(
[a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")],
[
h.div([a.class("p-6")], [
ui.heading_section("Redis Queue Depths Over Time"),
ui.text_small_muted(
"CDN purge and asset deletion queue sizes over time",
),
h.div([a.class("mt-4")], [
element.element(
"canvas",
[a.id("redisQueueChart"), a.attribute("height", "200")],
[],
),
]),
]),
],
),
h.div(
[a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")],
[
h.div([a.class("p-6")], [
ui.heading_section("Upload Activity Over Time"),
ui.text_small_muted(
"Attachment creation and expiry rates over time",
),
h.div([a.class("mt-4")], [
element.element(
"canvas",
[a.id("uploadActivityChart"), a.attribute("height", "200")],
[],
),
]),
]),
],
),
]),
h.div([a.class("mt-6")], [
h.div(
[a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")],
[
h.div([a.class("p-6")], [
ui.heading_section("Worker Job Queue Status"),
ui.text_small_muted("Graphile worker job queue overview"),
h.div([a.class("grid grid-cols-1 md:grid-cols-3 gap-4 mt-4")], [
render_loading_stat_card("Total Pending", "worker-pending"),
render_loading_stat_card("Total Running", "worker-running"),
render_loading_stat_card("Total Failed", "worker-failed"),
]),
]),
],
),
]),
h.div([a.class("mt-6")], [
h.div(
[a.class("bg-white border border-neutral-200 rounded-lg shadow-sm")],
[
h.div([a.class("p-6")], [
ui.heading_section("API Performance"),
ui.text_small_muted("API latency and error rates"),
h.div([a.class("grid grid-cols-1 md:grid-cols-4 gap-4 mt-4")], [
render_loading_stat_card("P50 Latency", "api-p50"),
render_loading_stat_card("P95 Latency", "api-p95"),
render_loading_stat_card("P99 Latency", "api-p99"),
render_loading_stat_card("5xx Errors (24h)", "api-5xx"),
]),
]),
],
),
]),
h.script([a.src("https://fluxerstatic.com/libs/chartjs/chart.min.js")], ""),
h.script([], render_charts_script(proxy_endpoint)),
])
}
fn render_stat_card(
label: String,
result: Result(metrics.AggregateResponse, common.ApiError),
) {
let value = case result {
Ok(resp) -> format_number(resp.total)
Error(_) -> "-"
}
h.div([a.class("bg-neutral-50 rounded-lg p-4 border border-neutral-200")], [
h.div([a.class("text-xs text-neutral-600 uppercase tracking-wider mb-1")], [
element.text(label),
]),
h.div([a.class("text-base font-semibold text-neutral-900")], [
element.text(value),
]),
])
}
fn render_storage_card(
result: Result(metrics.AggregateResponse, common.ApiError),
) {
let value = case result {
Ok(resp) -> format_bytes(resp.total)
Error(_) -> "-"
}
h.div([a.class("bg-neutral-50 rounded-lg p-4 border border-neutral-200")], [
h.div([a.class("text-xs text-neutral-600 uppercase tracking-wider mb-1")], [
element.text("Total Storage Used"),
]),
h.div([a.class("text-base font-semibold text-neutral-900")], [
element.text(value),
]),
])
}
fn render_loading_stat_card(label: String, id: String) {
h.div([a.class("bg-neutral-50 rounded-lg p-4 border border-neutral-200")], [
h.div([a.class("text-xs text-neutral-600 uppercase tracking-wider mb-1")], [
element.text(label),
]),
h.div([a.id(id), a.class("text-base font-semibold text-neutral-900")], [
element.text("-"),
]),
])
}
fn render_content_type_breakdown(
result: Result(metrics.AggregateResponse, common.ApiError),
) {
case result {
Ok(resp) ->
case resp.breakdown {
Some(breakdown) -> {
let sorted_breakdown =
breakdown
|> list.sort(fn(a, b) {
case b.value, a.value {
b_val, a_val ->
case float.compare(b_val, a_val) {
order.Gt -> order.Lt
order.Lt -> order.Gt
order.Eq -> order.Eq
}
}
})
h.ul(
[a.class("space-y-1 text-sm text-neutral-700")],
list.take(sorted_breakdown, 15)
|> list.map(render_breakdown_row),
)
}
None ->
h.div([a.class("text-neutral-500 text-sm")], [
element.text("No content type data available"),
])
}
Error(_) ->
h.div([a.class("text-neutral-500 text-sm")], [
element.text("Unable to load content type data"),
])
}
}
fn render_breakdown_row(entry: metrics.TopEntry) {
h.li(
[
a.class(
"flex justify-between py-1 border-b border-neutral-100 last:border-0",
),
],
[
h.span([a.class("text-neutral-700 font-mono text-xs")], [
element.text(entry.label),
]),
h.span([a.class("font-semibold text-neutral-900")], [
element.text(format_number(entry.value)),
]),
],
)
}
fn format_number(n: Float) -> String {
let int_val = float.truncate(n)
format_int_with_commas(int_val)
}
fn format_int_with_commas(n: Int) -> String {
let s = int.to_string(n)
let len = string.length(s)
case len {
_ if len <= 3 -> s
_ -> {
let groups = reverse_groups(s, [])
string.join(list.reverse(groups), ",")
}
}
}
fn reverse_groups(s: String, acc: List(String)) -> List(String) {
let len = string.length(s)
case len {
0 -> acc
_ if len <= 3 -> [s, ..acc]
_ -> {
let group = string.slice(s, len - 3, 3)
let rest = string.slice(s, 0, len - 3)
reverse_groups(rest, [group, ..acc])
}
}
}
fn format_bytes(bytes: Float) -> String {
case bytes {
_ if bytes <. 1024.0 -> format_number(bytes) <> " B"
_ if bytes <. 1_048_576.0 -> {
let kb = bytes /. 1024.0
float_to_string_rounded(kb, 2) <> " KB"
}
_ if bytes <. 1_073_741_824.0 -> {
let mb = bytes /. 1_048_576.0
float_to_string_rounded(mb, 2) <> " MB"
}
_ if bytes <. 1_099_511_627_776.0 -> {
let gb = bytes /. 1_073_741_824.0
float_to_string_rounded(gb, 2) <> " GB"
}
_ -> {
let tb = bytes /. 1_099_511_627_776.0
float_to_string_rounded(tb, 2) <> " TB"
}
}
}
fn float_to_string_rounded(value: Float, decimals: Int) -> String {
let multiplier = case decimals {
0 -> 1.0
1 -> 10.0
2 -> 100.0
3 -> 1000.0
_ -> 100.0
}
let rounded = float.round(value *. multiplier) |> int.to_float
let result = rounded /. multiplier
case decimals {
0 -> {
let int_value = float.round(result)
int.to_string(int_value)
}
_ -> {
let str = float.to_string(result)
case string.contains(str, ".") {
True -> str
False -> str <> ".0"
}
}
}
}
fn render_charts_script(metrics_endpoint: String) -> String {
"
(async function() {
const endpoint = '" <> metrics_endpoint <> "';
if (!endpoint) return;
const formatBytes = (bytes) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const formatMs = (ms) => {
if (ms === null || ms === undefined) return '-';
return ms.toFixed(2) + ' ms';
};
const formatNumber = (n) => {
if (n === null || n === undefined) return '-';
return n.toLocaleString();
};
const alignData = (data, timestamps) => {
const map = new Map(data.map(d => [d.timestamp, d.value]));
return timestamps.map(ts => map.get(ts) ?? null);
};
const formatTimeLabel = (ts) => {
const d = new Date(ts);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const getLatestValue = (data) => {
if (!data || data.length === 0) return null;
const sorted = [...data].sort((a, b) => b.timestamp - a.timestamp);
return sorted[0]?.value ?? null;
};
const get24hGrowth = (data) => {
if (!data || data.length < 2) return null;
const sorted = [...data].sort((a, b) => a.timestamp - b.timestamp);
const now = Date.now();
const oneDayAgo = now - 24 * 60 * 60 * 1000;
const recentPoints = sorted.filter(d => d.timestamp >= oneDayAgo);
if (recentPoints.length < 2) return null;
const first = recentPoints[0].value;
const last = recentPoints[recentPoints.length - 1].value;
return last - first;
};
try {
const [assetResp, cloudflareResp, bulkMsgResp, accountResp] = await Promise.all([
fetch(endpoint + '/query?metric=worker.redis_queue.asset_deletion').then(r => r.json()),
fetch(endpoint + '/query?metric=worker.redis_queue.cloudflare_purge').then(r => r.json()),
fetch(endpoint + '/query?metric=worker.redis_queue.bulk_message_deletion').then(r => r.json()),
fetch(endpoint + '/query?metric=worker.redis_queue.account_deletion').then(r => r.json())
]);
document.getElementById('redis-asset-deletion').textContent = formatNumber(getLatestValue(assetResp.data));
document.getElementById('redis-cloudflare-purge').textContent = formatNumber(getLatestValue(cloudflareResp.data));
document.getElementById('redis-bulk-message-deletion').textContent = formatNumber(getLatestValue(bulkMsgResp.data));
document.getElementById('redis-account-deletion').textContent = formatNumber(getLatestValue(accountResp.data));
const rqTimestamps = Array.from(new Set([
...assetResp.data.map(d => d.timestamp),
...cloudflareResp.data.map(d => d.timestamp),
])).sort((a, b) => a - b);
if (rqTimestamps.length > 0) {
new Chart(document.getElementById('redisQueueChart'), {
type: 'line',
data: {
labels: rqTimestamps.map(formatTimeLabel),
datasets: [
{ label: 'Cloudflare Purge', data: alignData(cloudflareResp.data, rqTimestamps), borderColor: 'rgb(251, 146, 60)', tension: 0.1, spanGaps: true },
{ label: 'Asset Deletion', data: alignData(assetResp.data, rqTimestamps), borderColor: 'rgb(239, 68, 68)', tension: 0.1, spanGaps: true }
]
},
options: {
responsive: true,
scales: { y: { beginAtZero: true, title: { display: true, text: 'Queue Size' } } },
plugins: { legend: { position: 'top' } }
}
});
}
} catch (e) {
console.error('Failed to load Redis queue stats:', e);
}
try {
const [pendingResp, runningResp, failedResp] = await Promise.all([
fetch(endpoint + '/query?metric=worker.queue.total_pending').then(r => r.json()),
fetch(endpoint + '/query?metric=worker.queue.total_running').then(r => r.json()),
fetch(endpoint + '/query?metric=worker.queue.total_failed').then(r => r.json())
]);
document.getElementById('worker-pending').textContent = formatNumber(getLatestValue(pendingResp.data));
document.getElementById('worker-running').textContent = formatNumber(getLatestValue(runningResp.data));
document.getElementById('worker-failed').textContent = formatNumber(getLatestValue(failedResp.data));
} catch (e) {
console.error('Failed to load worker stats:', e);
}
try {
const [latencyResp, status5xxResp] = await Promise.all([
fetch(endpoint + '/query/percentiles?metric=api.latency').then(r => r.json()),
fetch(endpoint + '/query?metric=api.request.5xx').then(r => r.json())
]);
const percentiles = latencyResp.percentiles;
document.getElementById('api-p50').textContent = percentiles ? formatMs(percentiles.p50) : '-';
document.getElementById('api-p95').textContent = percentiles ? formatMs(percentiles.p95) : '-';
document.getElementById('api-p99').textContent = percentiles ? formatMs(percentiles.p99) : '-';
const now = Date.now();
const oneDayAgo = now - 24 * 60 * 60 * 1000;
const recent5xx = status5xxResp.data.filter(d => d.timestamp >= oneDayAgo);
const total5xx = recent5xx.reduce((sum, d) => sum + d.value, 0);
document.getElementById('api-5xx').textContent = formatNumber(total5xx);
} catch (e) {
console.error('Failed to load API stats:', e);
}
try {
const storageResp = await fetch(endpoint + '/query?metric=attachment.storage.bytes').then(r => r.json());
const growth = get24hGrowth(storageResp.data);
const growthEl = document.getElementById('storage-growth-24h');
if (growthEl) {
if (growth !== null) {
const prefix = growth >= 0 ? '+' : '';
growthEl.textContent = prefix + formatBytes(Math.abs(growth));
growthEl.classList.add(growth >= 0 ? 'text-green-600' : 'text-red-600');
} else {
growthEl.textContent = '-';
}
}
const stTimestamps = storageResp.data.map(d => d.timestamp).sort((a, b) => a - b);
if (stTimestamps.length > 0) {
new Chart(document.getElementById('storageChart'), {
type: 'line',
data: {
labels: stTimestamps.map(formatTimeLabel),
datasets: [
{ label: 'Storage Bytes', data: alignData(storageResp.data, stTimestamps), borderColor: 'rgb(168, 85, 247)', backgroundColor: 'rgba(168, 85, 247, 0.1)', fill: true, tension: 0.1, spanGaps: true }
]
},
options: {
responsive: true,
scales: {
y: {
beginAtZero: true,
title: { display: true, text: 'Bytes' },
ticks: { callback: function(value) { return formatBytes(value); } }
}
},
plugins: {
legend: { position: 'top' },
tooltip: { callbacks: { label: function(context) { return context.dataset.label + ': ' + formatBytes(context.raw); } } }
}
}
});
}
} catch (e) {
console.error('Failed to load storage chart:', e);
}
try {
const [createdResp, expiredResp] = await Promise.all([
fetch(endpoint + '/query?metric=attachment.created').then(r => r.json()),
fetch(endpoint + '/query?metric=attachment.expired').then(r => r.json())
]);
const uaTimestamps = Array.from(new Set([
...createdResp.data.map(d => d.timestamp),
...expiredResp.data.map(d => d.timestamp),
])).sort((a, b) => a - b);
if (uaTimestamps.length > 0) {
new Chart(document.getElementById('uploadActivityChart'), {
type: 'line',
data: {
labels: uaTimestamps.map(formatTimeLabel),
datasets: [
{ label: 'Created', data: alignData(createdResp.data, uaTimestamps), borderColor: 'rgb(34, 197, 94)', tension: 0.1, spanGaps: true },
{ label: 'Expired', data: alignData(expiredResp.data, uaTimestamps), borderColor: 'rgb(239, 68, 68)', tension: 0.1, spanGaps: true }
]
},
options: {
responsive: true,
scales: { y: { beginAtZero: true, title: { display: true, text: 'Count' } } },
plugins: { legend: { position: 'top' } }
}
});
}
} catch (e) {
console.error('Failed to load upload activity chart:', e);
}
})();
"
}

View File

@@ -0,0 +1,220 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common
import fluxer_admin/components/ui
import fluxer_admin/constants
import gleam/int
import gleam/list
import gleam/option
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
pub fn render_flags_form(current_flags: String) {
let patchable_flags = constants.get_patchable_flags()
h.form(
[a.method("POST"), a.action("?action=update-flags"), a.id("flags-form")],
[
h.div(
[a.class("space-y-3")],
list.map(patchable_flags, fn(flag) {
render_flag_checkbox(flag, current_flags)
}),
),
h.div(
[
a.class("mt-6 pt-6 border-t border-neutral-200 hidden"),
a.id("flags-save-button"),
],
[
ui.button_primary("Save Changes", "submit", []),
],
),
],
)
}
pub fn render_flag_checkbox(flag: constants.Flag, current_flags: String) {
let is_checked = case int.parse(current_flags) {
Ok(flags_int) -> int.bitwise_and(flags_int, flag.value) == flag.value
Error(_) -> False
}
ui.custom_checkbox(
"flags[]",
int.to_string(flag.value),
flag.name,
is_checked,
option.Some(
"document.getElementById('flags-save-button').classList.remove('hidden')",
),
)
}
pub fn render_suspicious_flags_form(current_flags: Int) {
let suspicious_flags = constants.get_suspicious_activity_flags()
h.form(
[
a.method("POST"),
a.action("?action=update-suspicious-flags"),
a.id("suspicious-flags-form"),
],
[
h.div(
[a.class("space-y-3")],
list.map(suspicious_flags, fn(flag) {
render_suspicious_flag_checkbox(flag, current_flags)
}),
),
h.div(
[
a.class("mt-6 pt-6 border-t border-neutral-200 hidden"),
a.id("suspicious-flags-save-button"),
],
[
ui.button_primary("Save Changes", "submit", []),
],
),
],
)
}
pub fn render_suspicious_flag_checkbox(flag: constants.Flag, current_flags: Int) {
let is_checked = int.bitwise_and(current_flags, flag.value) == flag.value
ui.custom_checkbox(
"suspicious_flags[]",
int.to_string(flag.value),
flag.name,
is_checked,
option.Some(
"document.getElementById('suspicious-flags-save-button').classList.remove('hidden')",
),
)
}
pub fn render_acls_form(user: common.UserLookupResult, admin_acls: List(String)) {
let can_edit_acls =
list.contains(admin_acls, constants.acl_acl_set_user)
|| list.contains(admin_acls, constants.acl_wildcard)
let is_disabled = !can_edit_acls
case is_disabled {
True -> {
case list.is_empty(user.acls) {
True ->
h.p([a.class("text-sm text-neutral-500 italic")], [
element.text("No ACLs assigned"),
])
False ->
h.div(
[a.class("space-y-1")],
list.map(user.acls, fn(acl) {
h.div(
[
a.class(
"text-sm text-neutral-700 bg-neutral-50 px-2 py-1 rounded",
),
],
[element.text(acl)],
)
}),
)
}
}
False -> {
let all_acls = constants.get_all_acls()
h.form(
[a.method("POST"), a.action("?action=update-acls"), a.id("acls-form")],
[
h.div(
[a.class("space-y-2 max-h-96 overflow-y-auto overscroll-contain")],
list.map(all_acls, fn(acl) { render_acl_checkbox(acl, user.acls) }),
),
h.div(
[
a.class("mt-6 pt-6 border-t border-neutral-200 hidden"),
a.id("acls-save-button"),
],
[
h.button(
[
a.type_("submit"),
a.class(
"px-4 py-2 bg-neutral-900 text-white rounded-lg text-sm font-medium hover:bg-neutral-800 transition-colors",
),
],
[element.text("Save Changes")],
),
],
),
],
)
}
}
}
fn render_acl_checkbox(acl: String, current_acls: List(String)) {
let is_checked = list.contains(current_acls, acl)
h.label([a.class("flex items-center gap-3 cursor-pointer group")], [
h.input([
a.type_("checkbox"),
a.name("acls[]"),
a.value(acl),
a.checked(is_checked),
a.class("peer hidden"),
a.attribute(
"onchange",
"document.getElementById('acls-save-button').classList.remove('hidden')",
),
]),
element.element(
"svg",
[
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",
),
],
[
element.element(
"polyline",
[
a.attribute("points", "40 144 96 200 224 72"),
a.attribute("fill", "none"),
a.attribute("stroke", "currentColor"),
a.attribute("stroke-linecap", "round"),
a.attribute("stroke-linejoin", "round"),
a.attribute("stroke-width", "24"),
],
[],
),
],
),
h.span(
[
a.class("text-xs text-neutral-900 group-hover:text-neutral-700"),
],
[element.text(acl)],
),
])
}

View File

@@ -0,0 +1,827 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/messages
import fluxer_admin/api/users
import fluxer_admin/components/flash
import fluxer_admin/constants
import fluxer_admin/web.{type Context, type Session}
import gleam/int
import gleam/json
import gleam/list
import gleam/option
import gleam/result
import gleam/string
import wisp.{type Request, type Response}
pub fn handle_update_flags(
req: Request,
ctx: Context,
session: Session,
user_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let user_result = users.lookup_user(ctx, session, user_id)
case user_result {
Error(_) -> flash.redirect_with_error(ctx, redirect_url, "User not found")
Ok(option.None) ->
flash.redirect_with_error(ctx, redirect_url, "User not found")
Ok(option.Some(current_user)) -> {
let current_flags = case int.parse(current_user.flags) {
Ok(flags) -> flags
Error(_) -> 0
}
let submitted_flag_values =
list.filter_map(form_data.values, fn(field) {
case field.0 {
"flags[]" -> int.parse(field.1)
_ -> Error(Nil)
}
})
let all_user_flags = constants.get_patchable_flags()
let add_flags =
list.filter(submitted_flag_values, fn(flag) {
int.bitwise_and(current_flags, flag) == 0
})
|> list.map(int.to_string)
let remove_flags =
list.filter_map(all_user_flags, fn(flag_obj) {
let has_flag = int.bitwise_and(current_flags, flag_obj.value) != 0
let is_submitted =
list.contains(submitted_flag_values, flag_obj.value)
case has_flag && !is_submitted {
True -> Ok(int.to_string(flag_obj.value))
False -> Error(Nil)
}
})
let result =
users.update_user_flags(ctx, session, user_id, add_flags, remove_flags)
case result {
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"User flags updated successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to update user flags",
)
}
}
}
}
pub fn handle_update_suspicious_flags(
req: Request,
ctx: Context,
session: Session,
user_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let flag_values =
list.filter_map(form_data.values, fn(field) {
case field.0 {
"suspicious_flags[]" -> int.parse(field.1)
_ -> Error(Nil)
}
})
let total_flags =
list.fold(flag_values, 0, fn(acc, flag) { int.bitwise_or(acc, flag) })
let result =
users.update_suspicious_activity_flags(ctx, session, user_id, total_flags)
case result {
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"Suspicious activity flags updated successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to update suspicious activity flags",
)
}
}
pub fn handle_update_acls(
req: Request,
ctx: Context,
session: Session,
user_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let acls =
list.filter_map(form_data.values, fn(field) {
case field.0 {
"acls[]" -> Ok(field.1)
_ -> Error(Nil)
}
})
let result = users.set_user_acls(ctx, session, user_id, acls)
case result {
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"ACLs updated successfully",
)
Error(_) ->
flash.redirect_with_error(ctx, redirect_url, "Failed to update ACLs")
}
}
pub fn handle_disable_mfa(
ctx: Context,
session: Session,
user_id: String,
redirect_url: String,
) -> Response {
case users.disable_mfa(ctx, session, user_id) {
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"MFA disabled successfully",
)
Error(_) ->
flash.redirect_with_error(ctx, redirect_url, "Failed to disable MFA")
}
}
pub fn handle_verify_email(
ctx: Context,
session: Session,
user_id: String,
redirect_url: String,
) -> Response {
case users.verify_email(ctx, session, user_id) {
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"Email verified successfully",
)
Error(_) ->
flash.redirect_with_error(ctx, redirect_url, "Failed to verify email")
}
}
pub fn handle_unlink_phone(
ctx: Context,
session: Session,
user_id: String,
redirect_url: String,
) -> Response {
case users.unlink_phone(ctx, session, user_id) {
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"Phone unlinked successfully",
)
Error(_) ->
flash.redirect_with_error(ctx, redirect_url, "Failed to unlink phone")
}
}
pub fn handle_terminate_sessions(
ctx: Context,
session: Session,
user_id: String,
redirect_url: String,
) -> Response {
case users.terminate_sessions(ctx, session, user_id) {
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"Sessions terminated successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to terminate sessions",
)
}
}
pub fn handle_clear_fields(
req: Request,
ctx: Context,
session: Session,
user_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let fields =
list.filter_map(form_data.values, fn(field) {
case field.0 {
"fields[]" -> Ok(field.1)
_ -> Error(Nil)
}
})
case list.is_empty(fields) {
True ->
flash.redirect_with_error(
ctx,
redirect_url,
"No fields selected to clear",
)
False -> {
case users.clear_user_fields(ctx, session, user_id, fields) {
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"User fields cleared successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to clear user fields",
)
}
}
}
}
pub fn handle_set_bot_status(
req: Request,
ctx: Context,
session: Session,
user_id: String,
redirect_url: String,
) -> Response {
let query = wisp.get_query(req)
let status = list.key_find(query, "status") |> result.unwrap("false")
let bot = case status {
"true" -> True
_ -> False
}
case users.set_bot_status(ctx, session, user_id, bot) {
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"Bot status updated successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to update bot status",
)
}
}
pub fn handle_set_system_status(
req: Request,
ctx: Context,
session: Session,
user_id: String,
redirect_url: String,
) -> Response {
let query = wisp.get_query(req)
let status = list.key_find(query, "status") |> result.unwrap("false")
let system = case status {
"true" -> True
_ -> False
}
case users.set_system_status(ctx, session, user_id, system) {
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"System status updated successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to update system status",
)
}
}
pub fn handle_change_username(
req: Request,
ctx: Context,
session: Session,
user_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let username =
list.key_find(form_data.values, "username") |> result.unwrap("")
let discriminator =
list.key_find(form_data.values, "discriminator")
|> result.try(int.parse)
|> option.from_result
case username {
"" ->
flash.redirect_with_error(ctx, redirect_url, "Username cannot be empty")
_ -> {
case
users.change_username(ctx, session, user_id, username, discriminator)
{
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"Username changed successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to change username",
)
}
}
}
}
pub fn handle_change_email(
req: Request,
ctx: Context,
session: Session,
user_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let email = list.key_find(form_data.values, "email") |> result.unwrap("")
case email {
"" -> flash.redirect_with_error(ctx, redirect_url, "Email cannot be empty")
_ -> {
case users.change_email(ctx, session, user_id, email) {
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"Email changed successfully",
)
Error(_) ->
flash.redirect_with_error(ctx, redirect_url, "Failed to change email")
}
}
}
}
pub fn handle_temp_ban(
req: Request,
ctx: Context,
session: Session,
user_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let duration =
list.key_find(form_data.values, "duration")
|> result.try(int.parse)
|> result.unwrap(24)
let reason =
list.key_find(form_data.values, "reason")
|> option.from_result
let private_reason =
list.key_find(form_data.values, "private_reason")
|> option.from_result
case
users.temp_ban_user(ctx, session, user_id, duration, reason, private_reason)
{
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"User temporarily banned successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to temporarily ban user",
)
}
}
pub fn handle_unban(
ctx: Context,
session: Session,
user_id: String,
redirect_url: String,
) -> Response {
case users.unban_user(ctx, session, user_id) {
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"User unbanned successfully",
)
Error(_) ->
flash.redirect_with_error(ctx, redirect_url, "Failed to unban user")
}
}
pub fn handle_schedule_deletion(
req: Request,
ctx: Context,
session: Session,
user_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let reason_code =
list.key_find(form_data.values, "reason_code")
|> result.try(int.parse)
|> result.unwrap(0)
let days =
list.key_find(form_data.values, "days")
|> result.try(int.parse)
|> result.unwrap(30)
let public_reason =
list.key_find(form_data.values, "public_reason")
|> option.from_result
let private_reason =
list.key_find(form_data.values, "private_reason")
|> option.from_result
case
users.schedule_deletion(
ctx,
session,
user_id,
reason_code,
public_reason,
days,
private_reason,
)
{
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"Account deletion scheduled successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to schedule account deletion",
)
}
}
pub fn handle_cancel_deletion(
ctx: Context,
session: Session,
user_id: String,
redirect_url: String,
) -> Response {
case users.cancel_deletion(ctx, session, user_id) {
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"Account deletion cancelled successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to cancel account deletion",
)
}
}
pub fn handle_cancel_bulk_message_deletion(
ctx: Context,
session: Session,
user_id: String,
redirect_url: String,
) -> Response {
case users.cancel_bulk_message_deletion(ctx, session, user_id) {
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"Bulk message deletion cancelled successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to cancel bulk message deletion",
)
}
}
pub fn handle_delete_all_messages(
req: Request,
ctx: Context,
session: Session,
user_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let dry_run = case list.key_find(form_data.values, "dry_run") {
Ok(value) ->
case string.lowercase(value) {
"false" -> False
"0" -> False
_ -> True
}
Error(_) -> True
}
case messages.delete_all_user_messages(ctx, session, user_id, dry_run) {
Ok(response) -> {
case dry_run {
True -> {
let location =
redirect_url
|> append_query_param("delete_all_messages_dry_run", "true")
|> append_query_param(
"delete_all_messages_channel_count",
int.to_string(response.channel_count),
)
|> append_query_param(
"delete_all_messages_message_count",
int.to_string(response.message_count),
)
flash.redirect_with_success(
ctx,
location,
"Dry run found "
<> int.to_string(response.message_count)
<> " messages across "
<> int.to_string(response.channel_count)
<> " channels. Confirm to delete them permanently.",
)
}
False -> {
let location = case response.job_id {
option.Some(job_id) ->
append_query_param(redirect_url, "message_shred_job_id", job_id)
option.None -> redirect_url
}
let message = case response.job_id {
option.Some(_) ->
"Delete job queued. Monitor progress in the status panel."
option.None -> "No messages found for deletion."
}
flash.redirect_with_success(ctx, location, message)
}
}
}
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to delete all user messages",
)
}
}
pub fn handle_change_dob(
req: Request,
ctx: Context,
session: Session,
user_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let date_of_birth = list.key_find(form_data.values, "date_of_birth")
case date_of_birth {
Ok(dob) -> {
case users.change_dob(ctx, session, user_id, dob) {
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"Date of birth changed successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to change date of birth",
)
}
}
Error(_) ->
flash.redirect_with_error(ctx, redirect_url, "Invalid date of birth")
}
}
pub fn handle_send_password_reset(
ctx: Context,
session: Session,
user_id: String,
redirect_url: String,
) -> Response {
case users.send_password_reset(ctx, session, user_id) {
Ok(_) ->
flash.redirect_with_success(
ctx,
redirect_url,
"Password reset email sent successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to send password reset email",
)
}
}
pub fn handle_message_shred(
req: Request,
ctx: Context,
session: Session,
user_id: String,
redirect_url: String,
) -> Response {
use form_data <- wisp.require_form(req)
let csv_data =
list.key_find(form_data.values, "csv_data")
|> option.from_result
|> option.unwrap("")
let parsed_entries = parse_csv_entries(csv_data)
case parsed_entries {
Error(message) -> flash.redirect_with_error(ctx, redirect_url, message)
Ok(entries) -> {
case list.is_empty(entries) {
True ->
flash.redirect_with_error(
ctx,
redirect_url,
"CSV did not contain any valid channel_id,message_id pairs.",
)
False -> {
let entries_json =
json.array(entries, fn(entry) {
json.object([
#("channel_id", json.string(entry.0)),
#("message_id", json.string(entry.1)),
])
})
case
messages.queue_message_shred(ctx, session, user_id, entries_json)
{
Ok(response) -> {
let location =
append_query_param(
redirect_url,
"message_shred_job_id",
response.job_id,
)
flash.redirect_with_success(
ctx,
location,
"Message shred job queued",
)
}
Error(_) ->
flash.redirect_with_error(
ctx,
redirect_url,
"Failed to queue message shred job",
)
}
}
}
}
}
}
fn parse_csv_entries(
csv_data: String,
) -> Result(List(#(String, String)), String) {
let normalized = string.trim(csv_data)
let lines = string.split(normalized, "\n")
list.fold(lines, Ok([]), fn(acc_result, line) {
case acc_result {
Error(message) -> Error(message)
Ok(acc) -> {
let trimmed = string.trim(line)
case string.is_empty(trimmed) {
True -> Ok(acc)
False -> {
let normalized_lower = trimmed |> string.lowercase
case normalized_lower == "channel_id,message_id" {
True -> Ok(acc)
False -> {
case string.split(trimmed, ",") {
[channel_raw, message_raw] -> {
let channel_trim = string.trim(channel_raw)
let message_trim = string.trim(message_raw)
case int.parse(channel_trim) {
Ok(channel_value) ->
case int.parse(message_trim) {
Ok(message_value) ->
Ok(
list.append(acc, [
#(
int.to_string(channel_value),
int.to_string(message_value),
),
]),
)
Error(_) ->
Error("Invalid message_id on row: " <> trimmed)
}
Error(_) ->
Error("Invalid channel_id on row: " <> trimmed)
}
}
_ ->
Error(
"Each row must contain channel_id and message_id separated by a comma",
)
}
}
}
}
}
}
}
})
}
fn append_query_param(url: String, key: String, value: String) -> String {
let separator = case string.contains(url, "?") {
True -> "&"
False -> "?"
}
url <> separator <> key <> "=" <> value
}

View File

@@ -0,0 +1,553 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common
import fluxer_admin/api/users
import fluxer_admin/components/ui
import fluxer_admin/web.{type Context, type Session}
import gleam/list
import gleam/option
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
pub fn account_tab(
ctx: Context,
session: Session,
user: common.UserLookupResult,
user_id: String,
) {
let sessions_result = users.list_user_sessions(ctx, session, user_id)
h.div([a.class("space-y-6")], [
ui.card(ui.PaddingMedium, [
h.h2([a.class("text-base font-medium text-neutral-900 mb-4")], [
element.text("Edit Account Information"),
]),
h.div([a.class("grid grid-cols-1 md:grid-cols-2 gap-4")], [
h.form(
[
a.method("POST"),
a.action("?action=change-username&tab=account"),
a.class("space-y-2"),
a.attribute(
"onsubmit",
"return confirm('Are you sure you want to change this user\\'s username?')",
),
],
[
h.div([a.class("text-sm font-medium text-neutral-700")], [
element.text("Change Username:"),
]),
h.input([
a.type_("text"),
a.name("username"),
a.placeholder("New username"),
a.required(True),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
]),
h.input([
a.type_("number"),
a.name("discriminator"),
a.placeholder("Discriminator (optional)"),
a.min("0"),
a.max("9999"),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
]),
h.button(
[
a.type_("submit"),
a.class(
"w-full px-4 py-2 bg-neutral-900 text-white rounded text-sm font-medium hover:bg-neutral-800 transition-colors text-left",
),
],
[element.text("Change Username")],
),
],
),
h.form(
[
a.method("POST"),
a.action("?action=change-email&tab=account"),
a.class("space-y-2"),
a.attribute(
"onsubmit",
"return confirm('Are you sure you want to change this user\\'s email address?')",
),
],
[
h.div([a.class("text-sm font-medium text-neutral-700")], [
element.text("Change Email:"),
]),
h.input([
a.type_("email"),
a.name("email"),
a.placeholder("New email address"),
a.required(True),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
]),
h.button(
[
a.type_("submit"),
a.class(
"w-full px-4 py-2 bg-neutral-900 text-white rounded text-sm font-medium hover:bg-neutral-800 transition-colors",
),
],
[element.text("Change Email")],
),
],
),
h.form(
[
a.method("POST"),
a.action("?action=change-dob&tab=account"),
a.class("space-y-2"),
a.attribute(
"onsubmit",
"return confirm('Are you sure you want to change this user\\'s date of birth?')",
),
],
[
h.div([a.class("text-sm font-medium text-neutral-700")], [
element.text("Change Date of Birth:"),
]),
h.input([
a.type_("date"),
a.name("date_of_birth"),
a.value(option.unwrap(user.date_of_birth, "")),
a.required(True),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
]),
h.button(
[
a.type_("submit"),
a.class(
"w-full px-4 py-2 bg-neutral-900 text-white rounded text-sm font-medium hover:bg-neutral-800 transition-colors",
),
],
[element.text("Change Date of Birth")],
),
],
),
]),
]),
case sessions_result {
Ok(sessions_response) ->
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Active Sessions"),
case list.is_empty(sessions_response.sessions) {
True ->
h.p([a.class("text-sm text-neutral-600")], [
element.text("No active sessions"),
])
False ->
h.div(
[a.class("space-y-3")],
list.map(sessions_response.sessions, fn(session_item) {
h.div(
[
a.class(
"bg-neutral-50 border border-neutral-200 rounded-lg p-4",
),
],
[
h.div(
[
a.class(
"grid grid-cols-2 md:grid-cols-3 gap-x-6 gap-y-3 text-sm",
),
],
[
h.div([], [
h.div(
[a.class("text-neutral-500 text-sm font-medium")],
[
element.text("Platform"),
],
),
h.div([a.class("text-neutral-900")], [
element.text(session_item.client_platform),
]),
]),
h.div([], [
h.div(
[a.class("text-neutral-500 text-sm font-medium")],
[
element.text("OS"),
],
),
h.div([a.class("text-neutral-900")], [
element.text(session_item.client_os),
]),
]),
h.div([], [
h.div(
[a.class("text-neutral-500 text-sm font-medium")],
[
element.text("Location"),
],
),
h.div([a.class("text-neutral-900")], [
element.text(session_item.client_location),
]),
]),
h.div([], [
h.div(
[a.class("text-neutral-500 text-sm font-medium")],
[
element.text("IP Address"),
],
),
h.div([a.class("text-neutral-900 text-xs")], [
element.text(session_item.client_ip),
]),
]),
h.div([], [
h.div(
[a.class("text-neutral-500 text-sm font-medium")],
[
element.text("Last Used"),
],
),
h.div([a.class("text-neutral-900 text-xs")], [
element.text(session_item.approx_last_used_at),
]),
]),
],
),
],
)
}),
)
},
])
Error(_) -> element.none()
},
ui.card(ui.PaddingMedium, [
h.h2([a.class("text-base font-medium text-neutral-900 mb-4")], [
element.text("Quick Actions"),
]),
h.div([a.class("flex flex-wrap gap-3")], [
case user.email_verified {
False ->
h.form(
[a.method("POST"), a.action("?action=verify-email&tab=account")],
[
h.button(
[
a.type_("submit"),
a.class(
"px-4 py-2 bg-neutral-900 text-white rounded text-sm font-medium hover:bg-neutral-800 transition-colors text-sm",
),
],
[element.text("Verify Email")],
),
],
)
True -> element.none()
},
case user.phone {
option.Some(_) ->
h.form(
[
a.method("POST"),
a.action("?action=unlink-phone&tab=account"),
a.attribute(
"onsubmit",
"return confirm('Are you sure you want to unlink this user\\'s phone number?')",
),
],
[
h.button(
[
a.type_("submit"),
a.class(
"px-4 py-2 bg-neutral-900 text-white rounded text-sm font-medium hover:bg-neutral-800 transition-colors text-sm",
),
],
[element.text("Unlink Phone")],
),
],
)
option.None -> element.none()
},
h.form(
[
a.method("POST"),
a.action("?action=send-password-reset&tab=account"),
],
[
h.button(
[
a.type_("submit"),
a.class(
"px-4 py-2 bg-neutral-900 text-white rounded text-sm font-medium hover:bg-neutral-800 transition-colors text-sm",
),
],
[element.text("Send Password Reset")],
),
],
),
]),
]),
case
option.is_some(user.avatar)
|| option.is_some(user.banner)
|| option.is_some(user.bio)
|| option.is_some(user.pronouns)
{
True ->
ui.card(ui.PaddingMedium, [
h.h2([a.class("text-base font-medium text-neutral-900 mb-4")], [
element.text("Clear Profile Fields"),
]),
h.form(
[
a.method("POST"),
a.action("?action=clear-fields&tab=account"),
a.attribute(
"onsubmit",
"return confirm('Are you sure you want to clear the selected fields for this user?')",
),
],
[
h.div([a.class("grid grid-cols-2 md:grid-cols-3 gap-3 mb-4")], [
case user.avatar {
option.Some(_) ->
h.label([a.class("flex items-center gap-2 text-sm")], [
h.input([
a.type_("checkbox"),
a.name("fields[]"),
a.value("avatar"),
a.class("rounded"),
]),
element.text("Avatar"),
])
option.None -> element.none()
},
case user.banner {
option.Some(_) ->
h.label([a.class("flex items-center gap-2 text-sm")], [
h.input([
a.type_("checkbox"),
a.name("fields[]"),
a.value("banner"),
a.class("rounded"),
]),
element.text("Banner"),
])
option.None -> element.none()
},
case user.bio {
option.Some(_) ->
h.label([a.class("flex items-center gap-2 text-sm")], [
h.input([
a.type_("checkbox"),
a.name("fields[]"),
a.value("bio"),
a.class("rounded"),
]),
element.text("Bio"),
])
option.None -> element.none()
},
case user.pronouns {
option.Some(_) ->
h.label([a.class("flex items-center gap-2 text-sm")], [
h.input([
a.type_("checkbox"),
a.name("fields[]"),
a.value("pronouns"),
a.class("rounded"),
]),
element.text("Pronouns"),
])
option.None -> element.none()
},
case user.global_name {
option.Some(_) ->
h.label([a.class("flex items-center gap-2 text-sm")], [
h.input([
a.type_("checkbox"),
a.name("fields[]"),
a.value("global_name"),
a.class("rounded"),
]),
element.text("Display Name"),
])
option.None -> element.none()
},
]),
h.button(
[
a.type_("submit"),
a.class(
"w-full px-4 py-2 bg-neutral-900 text-white rounded text-sm font-medium hover:bg-neutral-800 transition-colors",
),
],
[element.text("Clear Selected Fields")],
),
],
),
])
False -> element.none()
},
ui.card(ui.PaddingMedium, [
h.h2([a.class("text-base font-medium text-neutral-900 mb-4")], [
element.text("User Status"),
]),
h.div([a.class("grid grid-cols-1 md:grid-cols-2 gap-4")], [
h.form(
[
a.method("POST"),
a.action(
"?action=set-bot-status&status="
<> case user.bot {
True -> "false"
False -> "true"
}
<> "&tab=account",
),
a.attribute(
"onsubmit",
"return confirm('Are you sure you want to "
<> case user.bot {
True -> "remove"
False -> "set"
}
<> " bot status for this user?')",
),
],
[
h.button(
[
a.type_("submit"),
a.class(
"w-full px-4 py-2 bg-neutral-900 text-white rounded text-sm font-medium hover:bg-neutral-800 transition-colors text-left",
),
],
[
element.text(case user.bot {
True -> "Remove Bot Status"
False -> "Set Bot Status"
}),
],
),
],
),
h.form(
[
a.method("POST"),
a.action(
"?action=set-system-status&status="
<> case user.system {
True -> "false"
False -> "true"
}
<> "&tab=account",
),
a.attribute(
"onsubmit",
"return confirm('Are you sure you want to "
<> case user.system {
True -> "remove"
False -> "set"
}
<> " system status for this user?')",
),
],
[
h.button(
[
a.type_("submit"),
a.class(
"w-full px-4 py-2 bg-neutral-900 text-white rounded text-sm font-medium hover:bg-neutral-800 transition-colors text-left",
),
],
[
element.text(case user.system {
True -> "Remove System Status"
False -> "Set System Status"
}),
],
),
],
),
]),
]),
ui.card(ui.PaddingMedium, [
h.h2([a.class("text-base font-medium text-neutral-900 mb-4")], [
element.text("Security Actions"),
]),
h.div([a.class("grid grid-cols-1 md:grid-cols-2 gap-3")], [
case user.has_totp {
True ->
h.form(
[
a.method("POST"),
a.action("?action=disable-mfa&tab=account"),
a.attribute(
"onsubmit",
"return confirm('Are you sure you want to disable MFA/TOTP for this user?')",
),
],
[
h.button(
[
a.type_("submit"),
a.class(
"w-full px-4 py-2 bg-neutral-900 text-white rounded text-sm font-medium hover:bg-neutral-800 transition-colors text-left",
),
],
[element.text("Disable MFA/TOTP")],
),
],
)
False -> element.none()
},
h.form(
[
a.method("POST"),
a.action("?action=terminate-sessions&tab=account"),
a.attribute(
"onsubmit",
"return confirm('Are you sure you want to terminate all sessions for this user?')",
),
],
[
h.button(
[
a.type_("submit"),
a.class(
"w-full px-4 py-2 bg-neutral-900 text-white rounded text-sm font-medium hover:bg-neutral-800 transition-colors text-left",
),
],
[element.text("Terminate All Sessions")],
),
],
),
]),
]),
])
}

View File

@@ -0,0 +1,157 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common
import fluxer_admin/api/users
import fluxer_admin/avatar
import fluxer_admin/components/ui
import fluxer_admin/web.{type Context, type Session, href}
import gleam/int
import gleam/list
import gleam/option
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
pub fn guilds_tab(
ctx: Context,
session: Session,
_user: common.UserLookupResult,
user_id: String,
) {
let guilds_result = users.list_user_guilds(ctx, session, user_id)
case guilds_result {
Ok(guilds_response) ->
h.div([a.class("space-y-6")], [
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin(
"Guilds ("
<> int.to_string(list.length(guilds_response.guilds))
<> ")",
),
case list.is_empty(guilds_response.guilds) {
True ->
h.p([a.class("text-sm text-neutral-600")], [
element.text("No guilds"),
])
False -> render_guilds_grid(ctx, guilds_response.guilds)
},
]),
])
Error(_) -> element.none()
}
}
fn render_guilds_grid(ctx: Context, guilds: List(users.UserGuild)) {
h.div(
[a.class("grid grid-cols-1 gap-4")],
list.map(guilds, fn(guild) { render_guild_card(ctx, guild) }),
)
}
fn render_guild_card(ctx: Context, guild: users.UserGuild) {
h.div(
[
a.class(
"bg-white border border-neutral-200 rounded-lg overflow-hidden hover:border-neutral-300 transition-colors",
),
],
[
h.div([a.class("p-5")], [
h.div([a.class("flex items-center gap-4")], [
case
avatar.get_guild_icon_url(
ctx.media_endpoint,
guild.id,
guild.icon,
True,
)
{
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"),
]),
])
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-1 min-w-0")], [
h.div([a.class("flex items-center gap-2 mb-2")], [
h.h2([a.class("text-base font-medium text-neutral-900")], [
element.text(guild.name),
]),
case list.is_empty(guild.features) {
False ->
h.span(
[
a.class(
"px-2 py-0.5 bg-purple-100 text-purple-700 text-xs rounded uppercase",
),
],
[element.text("Featured")],
)
True -> element.none()
},
]),
h.div([a.class("space-y-0.5")], [
h.div([a.class("text-sm text-neutral-600")], [
element.text("ID: " <> guild.id),
]),
h.div([a.class("text-sm text-neutral-600")], [
element.text("Members: " <> int.to_string(guild.member_count)),
]),
h.div([a.class("text-sm text-neutral-600")], [
element.text("Owner: "),
h.a(
[
href(ctx, "/users/" <> guild.owner_id),
a.class(
"hover:text-blue-600 hover:underline transition-colors",
),
],
[element.text(guild.owner_id)],
),
]),
]),
]),
h.a(
[
href(ctx, "/guilds/" <> guild.id),
a.class(
"px-4 py-2 bg-neutral-900 text-white rounded-lg text-sm hover:bg-neutral-800 transition-colors flex-shrink-0",
),
],
[element.text("View Details")],
),
]),
]),
],
)
}

View File

@@ -0,0 +1,665 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common
import fluxer_admin/api/messages
import fluxer_admin/components/date_time
import fluxer_admin/components/deletion_days_script
import fluxer_admin/components/ui
import fluxer_admin/constants
import fluxer_admin/web.{type Context, type Session, href}
import gleam/int
import gleam/list
import gleam/option
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
pub fn moderation_tab(
ctx: Context,
_session: Session,
user: common.UserLookupResult,
user_id: String,
admin_acls: List(String),
message_shred_job_id: option.Option(String),
message_shred_status_result: option.Option(
Result(messages.MessageShredStatus, common.ApiError),
),
delete_all_messages_dry_run: option.Option(#(Int, Int)),
) {
let temp_ban_durations = constants.get_temp_ban_durations()
let deletion_reasons = constants.get_deletion_reasons()
let can_shred_messages =
list.any(admin_acls, fn(acl) {
acl == constants.acl_message_shred || acl == constants.acl_wildcard
})
let can_delete_all_messages =
list.any(admin_acls, fn(acl) {
acl == constants.acl_message_delete_all || acl == constants.acl_wildcard
})
h.div([a.class("space-y-6")], [
h.div([a.class("grid grid-cols-1 md:grid-cols-2 gap-6")], [
ui.card(ui.PaddingMedium, [
h.h2([a.class("text-base font-medium text-neutral-900 mb-4")], [
element.text("Ban Actions"),
]),
h.div([a.class("space-y-4")], [
case user.temp_banned_until {
option.Some(_) ->
h.form(
[
a.method("POST"),
a.action("?action=unban&tab=moderation"),
a.attribute(
"onsubmit",
"return confirm('Are you sure you want to unban this user?')",
),
],
[
h.button(
[
a.type_("submit"),
a.class(
"w-full px-4 py-2 bg-neutral-900 text-white rounded text-sm font-medium hover:bg-neutral-800 transition-colors",
),
],
[element.text("Unban User")],
),
],
)
option.None ->
h.form(
[
a.method("POST"),
a.action("?action=temp-ban&tab=moderation"),
a.class("space-y-3"),
a.attribute(
"onsubmit",
"return confirm('Are you sure you want to temporarily ban this user?')",
),
],
[
h.div([], [
h.label(
[
a.class(
"block text-sm font-medium text-neutral-700 mb-1",
),
],
[element.text("Duration")],
),
h.select(
[
a.name("duration"),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900",
),
],
list.map(temp_ban_durations, fn(dur) {
h.option([a.value(int.to_string(dur.0))], dur.1)
}),
),
]),
h.div([], [
h.label(
[
a.class(
"block text-sm font-medium text-neutral-700 mb-1",
),
],
[element.text("Public Reason (optional)")],
),
h.input([
a.type_("text"),
a.name("reason"),
a.placeholder("Enter public ban reason..."),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900",
),
]),
]),
h.div([], [
h.label(
[
a.class(
"block text-sm font-medium text-neutral-700 mb-1",
),
],
[element.text("Private Reason (optional)")],
),
h.input([
a.type_("text"),
a.name("private_reason"),
a.placeholder("Enter private ban reason (audit log)..."),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900",
),
]),
]),
h.button(
[
a.type_("submit"),
a.class(
"w-full px-4 py-2 bg-neutral-900 text-white rounded text-sm font-medium hover:bg-neutral-800 transition-colors",
),
],
[element.text("Temporary Ban")],
),
],
)
},
]),
]),
ui.card(ui.PaddingMedium, [
h.h2([a.class("text-base font-medium text-neutral-900 mb-4")], [
element.text("Account Deletion"),
]),
h.div([a.class("space-y-4")], [
case user.pending_deletion_at {
option.Some(_) ->
h.form(
[
a.method("POST"),
a.action("?action=cancel-deletion&tab=moderation"),
a.attribute(
"onsubmit",
"return confirm('Are you sure you want to cancel the scheduled deletion for this user?')",
),
],
[
h.button(
[
a.type_("submit"),
a.class(
"w-full px-4 py-2 bg-neutral-900 text-white rounded text-sm font-medium hover:bg-neutral-800 transition-colors",
),
],
[element.text("Cancel Deletion")],
),
],
)
option.None ->
h.form(
[
a.method("POST"),
a.action("?action=schedule-deletion&tab=moderation"),
a.class("space-y-3"),
a.attribute(
"onsubmit",
"return confirm('Are you sure you want to schedule this user account for deletion? This action will permanently delete the account after the specified number of days.')",
),
],
[
h.div([], [
h.label(
[
a.class(
"block text-sm font-medium text-neutral-700 mb-1",
),
],
[element.text("Days until deletion")],
),
h.input([
a.type_("number"),
a.id("user-deletion-days"),
a.name("days"),
a.value("14"),
a.min("14"),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900",
),
]),
]),
h.div([], [
h.label(
[
a.class(
"block text-sm font-medium text-neutral-700 mb-1",
),
],
[element.text("Reason")],
),
h.select(
[
a.id("user-deletion-reason"),
a.name("reason_code"),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900",
),
],
list.map(deletion_reasons, fn(reason) {
h.option([a.value(int.to_string(reason.0))], reason.1)
}),
),
]),
h.div([], [
h.label(
[
a.class(
"block text-sm font-medium text-neutral-700 mb-1",
),
],
[element.text("Public Reason (optional)")],
),
h.input([
a.type_("text"),
a.name("public_reason"),
a.placeholder("Enter public reason..."),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900",
),
]),
]),
h.div([], [
h.label(
[
a.class(
"block text-sm font-medium text-neutral-700 mb-1",
),
],
[element.text("Private Reason (optional)")],
),
h.input([
a.type_("text"),
a.name("private_reason"),
a.placeholder("Enter private reason (audit log)..."),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900",
),
]),
]),
h.button(
[
a.type_("submit"),
a.class(
"w-full px-4 py-2 bg-neutral-900 text-white rounded text-sm font-medium hover:bg-neutral-800 transition-colors",
),
],
[element.text("Schedule Deletion")],
),
],
)
},
]),
deletion_days_script.render(),
]),
]),
case can_delete_all_messages {
True ->
render_delete_all_messages_section(
ctx,
user_id,
delete_all_messages_dry_run,
)
False -> element.none()
},
case can_shred_messages {
True ->
render_message_shred_section(
ctx,
user_id,
message_shred_job_id,
message_shred_status_result,
)
False -> element.none()
},
])
}
fn render_delete_all_messages_section(
_ctx: Context,
_user_id: String,
dry_run_data: option.Option(#(Int, Int)),
) {
ui.card(ui.PaddingMedium, [
h.h2([a.class("text-base font-medium text-neutral-900 mb-4")], [
element.text("Delete All Messages"),
]),
h.p([a.class("body-sm text-neutral-600 mb-3")], [
element.text(
"Locate every message this user has ever sent and permanently remove them. First run a dry run to see how many channels and messages will be affected.",
),
]),
h.form(
[
a.method("POST"),
a.action("?action=delete-all-messages&tab=moderation"),
a.class("space-y-3"),
],
[
h.input([
a.type_("hidden"),
a.name("dry_run"),
a.value("true"),
]),
h.button(
[
a.type_("submit"),
a.class(
"w-full px-4 py-2 bg-neutral-900 text-white rounded text-sm font-medium hover:bg-neutral-800 transition-colors",
),
],
[element.text("Preview Deletion")],
),
],
),
case dry_run_data {
option.Some(#(channel_count, message_count)) ->
h.div(
[
a.class(
"mt-4 space-y-3 bg-neutral-50 border border-neutral-200 rounded-lg p-4",
),
],
[
h.div([], [
h.p([a.class("body-sm text-neutral-700")], [
element.text(
"Channels:"
<> " "
<> int.to_string(channel_count)
<> " · Messages: "
<> int.to_string(message_count),
),
]),
]),
h.form(
[
a.method("POST"),
a.action("?action=delete-all-messages&tab=moderation"),
a.attribute(
"onsubmit",
"return confirm('This will permanently delete every message this user has ever sent. Continue?')",
),
],
[
h.input([
a.type_("hidden"),
a.name("dry_run"),
a.value("false"),
]),
h.button(
[
a.type_("submit"),
a.class(
"w-full px-4 py-2 bg-red-600 text-white rounded text-sm font-medium hover:bg-red-500 transition-colors",
),
],
[element.text("Delete All Messages")],
),
],
),
],
)
option.None -> element.none()
},
])
}
fn render_message_shred_section(
ctx: Context,
user_id: String,
job_id: option.Option(String),
status_result: option.Option(
Result(messages.MessageShredStatus, common.ApiError),
),
) {
let entry_hint =
"Upload a CSV file where each row includes the channel_id and message_id separated by a comma. Large files are chunked server-side automatically."
ui.card(ui.PaddingMedium, [
h.h2([a.class("text-base font-medium text-neutral-900 mb-4")], [
element.text("Message Shredder"),
]),
h.p([a.class("body-sm text-neutral-600 mb-3")], [
element.text(entry_hint),
]),
h.form(
[
a.method("POST"),
a.action("?action=message-shred&tab=moderation"),
a.id("message-shred-form"),
a.class("space-y-3"),
],
[
h.input([
a.type_("hidden"),
a.name("csv_data"),
a.id("message-shred-csv-data"),
]),
h.label([a.class("block text-sm font-medium text-neutral-700")], [
element.text("CSV File"),
]),
h.input([
a.id("message-shred-file"),
a.type_("file"),
a.accept([".csv"]),
a.class(
"w-full px-3 py-2 border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900 text-sm",
),
]),
h.button(
[
a.type_("submit"),
a.id("message-shred-submit"),
a.class(
"w-full px-4 py-2 bg-neutral-900 text-white rounded text-sm font-medium hover:bg-neutral-800 transition-colors",
),
],
[element.text("Shred Messages")],
),
],
),
render_message_shred_status(ctx, user_id, job_id, status_result),
message_shred_form_script(),
])
}
fn render_message_shred_status(
ctx: Context,
user_id: String,
job_id: option.Option(String),
status_result: option.Option(
Result(messages.MessageShredStatus, common.ApiError),
),
) {
case job_id {
option.Some(_) ->
h.div(
[a.class("bg-white border border-neutral-200 rounded-lg p-4 space-y-3")],
[
h.div([a.class("flex items-center justify-between")], [
h.h2([a.class("subtitle text-neutral-900")], [
element.text("Message Shred Status"),
]),
h.a(
[
href(ctx, "/users/" <> user_id <> "?tab=moderation"),
a.class("body-sm text-neutral-600"),
],
[
element.text("Clear"),
],
),
]),
case status_result {
option.Some(result) ->
case result {
Ok(status) -> render_message_shred_status_content(status)
Error(common.NotFound) ->
h.p([a.class("body-sm text-neutral-700")], [
element.text("Preparing job… check back in a moment."),
])
Error(err) -> render_status_error(err)
}
option.None ->
h.p([a.class("body-sm text-neutral-700")], [
element.text("Preparing job… check back in a moment."),
])
},
],
)
option.None -> element.none()
}
}
fn render_message_shred_status_content(status: messages.MessageShredStatus) {
let percentage = case status.total, status.processed {
option.Some(total), option.Some(processed) ->
case total {
0 -> 0
_ -> processed * 100 / total
}
_, _ -> 0
}
h.div([a.class("space-y-3")], [
h.p([a.class("body-sm text-neutral-700")], [
element.text(
"Status: " <> format_message_shred_status_label(status.status),
),
]),
case status.requested, status.skipped {
option.Some(requested), option.Some(skipped) ->
h.p([a.class("body-sm text-neutral-700")], [
element.text(
"Requested "
<> int.to_string(requested)
<> " entries, skipped "
<> int.to_string(skipped)
<> " entries",
),
])
option.Some(requested), option.None ->
h.p([a.class("body-sm text-neutral-700")], [
element.text("Requested " <> int.to_string(requested) <> " entries"),
])
_, _ -> element.none()
},
case status.status, status.total, status.processed {
"in_progress", option.Some(total), option.Some(processed) ->
h.div([a.class("space-y-2")], [
h.div([a.class("flex justify-between body-sm text-neutral-700")], [
h.span([], [
element.text(
int.to_string(processed)
<> " / "
<> int.to_string(total)
<> " ("
<> int.to_string(percentage)
<> "%)",
),
]),
]),
h.div(
[a.class("w-full bg-neutral-200 rounded-full h-2 overflow-hidden")],
[
h.div(
[
a.class("bg-neutral-900 h-2 transition-[width] duration-300"),
a.attribute(
"style",
"width: " <> int.to_string(percentage) <> "%",
),
],
[],
),
],
),
])
"completed", option.Some(total), option.Some(processed) ->
h.p([a.class("body-sm text-neutral-700")], [
element.text(
"Deleted "
<> int.to_string(processed)
<> " / "
<> int.to_string(total)
<> " entries",
),
])
_, _, _ -> element.none()
},
case status.started_at {
option.Some(timestamp) ->
h.p([a.class("caption text-neutral-500")], [
element.text("Started " <> date_time.format_timestamp(timestamp)),
])
option.None -> element.none()
},
case status.completed_at {
option.Some(timestamp) ->
h.p([a.class("caption text-neutral-500")], [
element.text("Completed " <> date_time.format_timestamp(timestamp)),
])
option.None -> element.none()
},
case status.failed_at {
option.Some(timestamp) ->
h.p([a.class("caption text-red-600")], [
element.text("Failed " <> date_time.format_timestamp(timestamp)),
])
option.None -> element.none()
},
case status.error {
option.Some(message) ->
h.p([a.class("body-sm text-red-600")], [element.text(message)])
option.None -> element.none()
},
])
}
fn format_message_shred_status_label(status: String) -> String {
case status {
"in_progress" -> "In progress"
"completed" -> "Completed"
"failed" -> "Failed"
"not_found" -> "Preparing"
_ -> "Unknown"
}
}
fn render_status_error(err: common.ApiError) {
let #(title, message) = case err {
common.Unauthorized -> #(
"Authentication Required",
"Your session has expired. Please log in again.",
)
common.Forbidden(msg) -> #("Permission Denied", msg)
common.NotFound -> #("Not Found", "Status information not found.")
common.ServerError -> #(
"Server Error",
"An internal server error occurred. Please try again later.",
)
common.NetworkError -> #(
"Network Error",
"Could not connect to the API. Please try again later.",
)
}
h.div([a.class("space-y-1 body-sm text-red-600")], [
h.p([], [element.text(title)]),
h.p([], [element.text(message)]),
])
}
fn message_shred_form_script() {
let script =
"(function(){const form=document.getElementById('message-shred-form');if(!form)return;const file=document.getElementById('message-shred-file');const csvInput=document.getElementById('message-shred-csv-data');const submitButton=document.getElementById('message-shred-submit');if(!file||!csvInput||!submitButton)return;let processing=false;const handle=(event)=>{if(processing){event.preventDefault();return;}const selected=file.files&&file.files[0];if(!selected){event.preventDefault();alert('Please select a CSV file to continue.');return;}event.preventDefault();processing=true;submitButton.disabled=true;submitButton.textContent='Processing…';const reader=new FileReader();reader.onload=()=>{csvInput.value=reader.result||'';form.submit();};reader.onerror=()=>{processing=false;submitButton.disabled=false;submitButton.textContent='Shred Messages';alert('Failed to read the CSV file. Please try again.');};reader.readAsText(selected);};form.addEventListener('submit',handle);})();"
h.script([a.attribute("defer", "defer")], script)
}

View File

@@ -0,0 +1,380 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/users
import fluxer_admin/components/helpers
import fluxer_admin/components/icons
import fluxer_admin/components/ui
import fluxer_admin/constants
import fluxer_admin/pages/user_detail/forms
import fluxer_admin/user
import fluxer_admin/web.{type Context}
import gleam/int
import gleam/list
import gleam/option
import gleam/string
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
pub fn overview_tab(
_ctx: Context,
user: common.UserLookupResult,
admin_acls: List(String),
change_log_result: Result(users.ListUserChangeLogResponse, common.ApiError),
) {
h.div([a.class("space-y-6")], [
case user.temp_banned_until, user.pending_deletion_at {
option.Some(until), _ ->
h.div([a.class("p-4 bg-red-50 border border-red-200 rounded-lg")], [
h.div(
[
a.class(
"flex items-center gap-2 text-red-900 text-sm font-medium",
),
],
[
element.text("Temporarily Banned Until: " <> until),
],
),
])
_, option.Some(deletion_date) ->
h.div(
[a.class("p-4 bg-orange-50 border border-orange-200 rounded-lg")],
[
h.div([a.class("text-orange-900 text-sm font-medium")], [
element.text("Scheduled for Deletion: " <> deletion_date),
]),
case user.deletion_reason_code, user.deletion_public_reason {
option.Some(code), option.Some(reason) ->
h.div([a.class("text-sm text-orange-700 mt-1")], [
element.text(
"Reason: "
<> reason
<> " (code: "
<> int.to_string(code)
<> ")",
),
])
option.Some(code), option.None ->
h.div([a.class("text-sm text-orange-700 mt-1")], [
element.text("Reason code: " <> int.to_string(code)),
])
option.None, option.Some(reason) ->
h.div([a.class("text-sm text-orange-700 mt-1")], [
element.text("Reason: " <> reason),
])
_, _ -> element.none()
},
],
)
_, _ -> element.none()
},
case user.pending_bulk_message_deletion_at {
option.Some(deletion_date) ->
h.div(
[a.class("p-4 bg-yellow-50 border border-yellow-200 rounded-lg")],
[
h.div([a.class("text-yellow-900 text-sm font-medium")], [
element.text(
"Bulk message deletion scheduled for: " <> deletion_date,
),
]),
case
acl.has_permission(
admin_acls,
constants.acl_user_cancel_bulk_message_deletion,
)
{
True ->
h.form(
[
a.method("POST"),
a.action(
"?action=cancel-bulk-message-deletion&tab=overview",
),
a.attribute(
"onsubmit",
"return confirm('Are you sure you want to cancel the scheduled bulk message deletion for this user?')",
),
],
[
h.button(
[
a.type_("submit"),
a.class(
"w-full px-4 py-2 bg-neutral-900 text-white rounded text-sm font-medium hover:bg-neutral-800 transition-colors mt-3",
),
],
[element.text("Cancel Bulk Message Deletion")],
),
],
)
False -> element.none()
},
],
)
option.None -> element.none()
},
h.div([a.class("grid grid-cols-1 md:grid-cols-3 gap-6 items-start")], [
h.div([a.class("md:col-span-2 space-y-6")], [
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Account Information"),
h.div(
[a.class("grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2 text-sm")],
[
helpers.compact_info_mono("User ID", user.id),
case user.extract_timestamp(user.id) {
Ok(created_at) -> helpers.compact_info("Created", created_at)
Error(_) -> element.none()
},
helpers.compact_info(
"Username",
user.username
<> "#"
<> user.format_discriminator(user.discriminator),
),
helpers.compact_info_with_element("Email", case user.email {
option.Some(email) ->
h.span([], [
h.span([a.class("")], [element.text(email)]),
element.text(" "),
case user.email_verified {
True -> icons.checkmark_icon("text-green-600")
False -> icons.x_icon("text-red-600")
},
case user.email_bounced {
True ->
h.span([a.class("text-orange-600 ml-1")], [
element.text("(bounced)"),
])
False -> element.none()
},
])
option.None ->
h.span([a.class("text-neutral-500")], [
element.text("Not set"),
])
}),
helpers.compact_info("Phone", case user.phone {
option.Some(phone) -> phone
option.None -> "Not set"
}),
helpers.compact_info("Date of Birth", case user.date_of_birth {
option.Some(dob) -> dob
option.None -> "Not set"
}),
helpers.compact_info("Locale", case user.locale {
option.Some(locale) -> locale
option.None -> "Not set"
}),
case user.bio {
option.Some(bio) ->
h.div([a.class("md:col-span-2")], [
helpers.compact_info("Bio", bio),
])
option.None -> element.none()
},
case user.pronouns {
option.Some(pronouns) ->
helpers.compact_info("Pronouns", pronouns)
option.None -> element.none()
},
helpers.compact_info("Bot", case user.bot {
True -> "Yes"
False -> "No"
}),
helpers.compact_info("System", case user.system {
True -> "Yes"
False -> "No"
}),
helpers.compact_info("Last Active", case user.last_active_at {
option.Some(at) -> at
option.None -> "Never"
}),
helpers.compact_info_with_element(
"Last Active IP",
case user.last_active_ip {
option.Some(ip) ->
h.span([], [
h.span([a.class("font-mono")], [element.text(ip)]),
case user.last_active_ip_reverse {
option.Some(reverse) ->
h.span([a.class("text-neutral-500 ml-2")], [
element.text("(" <> reverse <> ")"),
])
option.None -> element.none()
},
])
option.None ->
h.span([a.class("text-neutral-500")], [
element.text("Not recorded"),
])
},
),
helpers.compact_info("Location", case user.last_active_location {
option.Some(location) -> location
option.None -> "Unknown Location"
}),
],
),
]),
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Security & Premium"),
h.div(
[a.class("grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2 text-sm")],
[
helpers.compact_info(
"Authenticators",
case list.is_empty(user.authenticator_types) {
True -> "None"
False -> {
let types =
list.map(user.authenticator_types, fn(t) {
case t {
0 -> "TOTP"
1 -> "SMS"
2 -> "WebAuthn"
_ -> "Unknown"
}
})
string.join(types, ", ")
}
},
),
helpers.compact_info("Premium Type", case user.premium_type {
option.Some(0) | option.None -> "None"
option.Some(1) -> "Subscription"
option.Some(2) -> "Lifetime"
option.Some(_) -> "Unknown"
}),
case user.premium_since {
option.Some(since) ->
helpers.compact_info("Premium Since", since)
option.None -> element.none()
},
case user.premium_until {
option.Some(until) ->
helpers.compact_info("Premium Until", until)
option.None -> element.none()
},
],
),
]),
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("User Flags"),
forms.render_flags_form(user.flags),
]),
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Suspicious Activity Flags"),
forms.render_suspicious_flags_form(user.suspicious_activity_flags),
]),
render_change_log(change_log_result),
]),
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Admin ACLs"),
forms.render_acls_form(user, admin_acls),
]),
]),
])
}
fn render_change_log(
change_log_result: Result(users.ListUserChangeLogResponse, common.ApiError),
) {
ui.card(ui.PaddingMedium, [
ui.heading_card_with_margin("Contact Change Log"),
case change_log_result {
Ok(resp) -> render_change_log_entries(resp.entries)
Error(err) ->
h.div([a.class("text-sm text-red-700")], [
element.text("Failed to load change log: " <> format_error(err)),
])
},
])
}
fn render_change_log_entries(entries: List(users.ContactChangeLogEntry)) {
case entries {
[] ->
h.div([a.class("text-sm text-neutral-600")], [
element.text("No contact changes recorded."),
])
_ ->
h.ul(
[a.class("divide-y divide-neutral-200")],
list.map(entries, render_entry),
)
}
}
fn render_entry(entry: users.ContactChangeLogEntry) {
h.li([a.class("py-3 flex flex-col gap-1")], [
h.div([a.class("flex items-center gap-2 text-sm")], [
h.span([a.class("font-medium text-neutral-900")], [
element.text(label_for_field(entry.field)),
]),
h.span([a.class("text-neutral-500")], [element.text(entry.event_at)]),
]),
h.div([a.class("text-sm text-neutral-800")], [
element.text(old_new_text(entry.old_value, entry.new_value)),
]),
h.div([a.class("text-xs text-neutral-600")], [
element.text("Reason: " <> entry.reason),
case entry.actor_user_id {
option.Some(actor) -> element.text(" • Actor: " <> actor)
option.None -> element.none()
},
]),
])
}
fn label_for_field(field: String) {
case field {
"email" -> "Email"
"phone" -> "Phone"
"fluxer_tag" -> "FluxerTag"
_ -> field
}
}
fn old_new_text(
old_value: option.Option(String),
new_value: option.Option(String),
) {
let old_display = case old_value {
option.Some(v) -> v
option.None -> "null"
}
let new_display = case new_value {
option.Some(v) -> v
option.None -> "null"
}
old_display <> "" <> new_display
}
fn format_error(err: common.ApiError) {
case err {
common.Unauthorized -> "Unauthorized"
common.Forbidden(message) -> message
common.NotFound -> "Not found"
common.ServerError -> "Server error"
common.NetworkError -> "Network error"
}
}

View File

@@ -0,0 +1,608 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/archives
import fluxer_admin/api/common
import fluxer_admin/api/messages
import fluxer_admin/api/users
import fluxer_admin/avatar
import fluxer_admin/badge
import fluxer_admin/components/date_time
import fluxer_admin/components/errors
import fluxer_admin/components/flash
import fluxer_admin/components/layout
import fluxer_admin/components/tabs
import fluxer_admin/components/ui
import fluxer_admin/constants
import fluxer_admin/pages/user_detail/handlers
import fluxer_admin/pages/user_detail/tabs/account
import fluxer_admin/pages/user_detail/tabs/guilds
import fluxer_admin/pages/user_detail/tabs/moderation
import fluxer_admin/pages/user_detail/tabs/overview
import fluxer_admin/user
import fluxer_admin/web.{type Context, type Session, action, href, redirect}
import gleam/int
import gleam/list
import gleam/option
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Request, type Response}
pub fn view(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
user_id: String,
referrer: option.Option(String),
tab: option.Option(String),
message_shred_job_id: option.Option(String),
delete_all_messages_dry_run: option.Option(#(Int, Int)),
) -> Response {
let result = users.lookup_user(ctx, session, user_id)
let change_log_result = users.list_user_change_log(ctx, session, user_id)
let admin_acls = case current_admin {
option.Some(admin) -> admin.acls
_ -> []
}
let message_shred_status_result = case message_shred_job_id {
option.Some(job_id) ->
option.Some(messages.get_message_shred_status(ctx, session, job_id))
option.None -> option.None
}
let can_view_archives =
list.any(admin_acls, fn(acl) {
acl == constants.acl_archive_view_all
|| acl == constants.acl_archive_trigger_user
|| acl == constants.acl_wildcard
})
let active_tab = case tab {
option.Some("account") -> "account"
option.Some("moderation") -> "moderation"
option.Some("guilds") -> "guilds"
option.Some("archives") -> "archives"
_ -> "overview"
}
let active_tab = case active_tab == "archives" && can_view_archives == False {
True -> "overview"
False -> active_tab
}
let should_auto_refresh = case message_shred_status_result {
option.Some(result) ->
case result {
Ok(status) ->
status.status == "in_progress" || status.status == "not_found"
Error(common.NotFound) -> True
Error(_) -> False
}
option.None -> False
}
let content = case result {
Ok(option.Some(user_data)) -> {
let badges = badge.get_user_badges(ctx.cdn_endpoint, user_data.flags)
h.div([a.class("max-w-7xl mx-auto")], [
h.div([a.class("mb-6")], [
h.a(
[
href(ctx, option.unwrap(referrer, "/users")),
a.class(
"inline-flex items-center gap-2 text-neutral-600 hover:text-neutral-900 transition-colors",
),
],
[
h.span([a.class("text-lg")], [element.text("")]),
element.text("Back to Users"),
],
),
]),
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),
]),
]),
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(
ctx,
session,
user_data,
admin_acls,
user_id,
active_tab,
change_log_result,
message_shred_job_id,
message_shred_status_result,
delete_all_messages_dry_run,
),
])
}
Ok(option.None) -> not_found_view(ctx)
Error(err) ->
errors.api_error_view(
ctx,
err,
option.Some("/users"),
option.Some("Back to Users"),
)
}
let html =
layout.page_with_refresh(
"User Details",
"users",
ctx,
session,
current_admin,
flash_data,
content,
should_auto_refresh,
)
wisp.html_response(element.to_document_string(html), 200)
}
fn render_tabs(
ctx: Context,
session: Session,
user: common.UserLookupResult,
admin_acls: List(String),
user_id: String,
active_tab: String,
change_log_result: Result(users.ListUserChangeLogResponse, common.ApiError),
message_shred_job_id: option.Option(String),
message_shred_status_result: option.Option(
Result(messages.MessageShredStatus, common.ApiError),
),
delete_all_messages_dry_run: option.Option(#(Int, Int)),
) {
let can_view_archives =
list.any(admin_acls, fn(acl) {
acl == constants.acl_archive_view_all
|| acl == constants.acl_archive_trigger_user
|| acl == constants.acl_wildcard
})
let tab_list = [
tabs.Tab(
label: "Overview",
path: "/users/" <> user_id <> "?tab=overview",
active: active_tab == "overview",
),
tabs.Tab(
label: "Account",
path: "/users/" <> user_id <> "?tab=account",
active: active_tab == "account",
),
tabs.Tab(
label: "Moderation",
path: "/users/" <> user_id <> "?tab=moderation",
active: active_tab == "moderation",
),
tabs.Tab(
label: "Guilds",
path: "/users/" <> user_id <> "?tab=guilds",
active: active_tab == "guilds",
),
]
let tab_list = case can_view_archives {
True ->
tab_list
|> list.append([
tabs.Tab(
label: "Archives",
path: "/users/" <> user_id <> "?tab=archives",
active: active_tab == "archives",
),
])
False -> tab_list
}
h.div([], [
tabs.render_tabs(ctx, tab_list),
case active_tab {
"account" -> account.account_tab(ctx, session, user, user_id)
"moderation" ->
moderation.moderation_tab(
ctx,
session,
user,
user_id,
admin_acls,
message_shred_job_id,
message_shred_status_result,
delete_all_messages_dry_run,
)
"guilds" -> guilds.guilds_tab(ctx, session, user, user_id)
"archives" -> archives_tab(ctx, session, user_id)
_ -> overview.overview_tab(ctx, user, admin_acls, change_log_result)
},
])
}
fn not_found_view(ctx: Context) {
h.div([a.class("max-w-4xl mx-auto")], [
h.div(
[
a.class(
"bg-white border border-neutral-200 rounded-lg p-12 text-center",
),
],
[
h.h2([a.class("text-base font-semibold text-neutral-900 mb-2")], [
element.text("User Not Found"),
]),
h.p([a.class("text-neutral-600 mb-6")], [
element.text("The requested user could not be found."),
]),
h.a(
[
href(ctx, "/users"),
a.class(
"inline-flex items-center gap-2 px-4 py-2 bg-neutral-900 text-white rounded-lg text-sm font-medium hover:bg-neutral-800 transition-colors",
),
],
[
h.span([a.class("text-lg")], [element.text("")]),
element.text("Back to Users"),
],
),
],
),
])
}
pub fn handle_action(
req: Request,
ctx: Context,
session: Session,
user_id: String,
action: option.Option(String),
tab: option.Option(String),
) -> Response {
let redirect_url = case tab {
option.Some(t) -> "/users/" <> user_id <> "?tab=" <> t
option.None -> "/users/" <> user_id
}
case action {
option.Some("update-flags") ->
handlers.handle_update_flags(req, ctx, session, user_id, redirect_url)
option.Some("update-suspicious-flags") ->
handlers.handle_update_suspicious_flags(
req,
ctx,
session,
user_id,
redirect_url,
)
option.Some("update-acls") ->
handlers.handle_update_acls(req, ctx, session, user_id, redirect_url)
option.Some("disable-mfa") ->
handlers.handle_disable_mfa(ctx, session, user_id, redirect_url)
option.Some("change-username") ->
handlers.handle_change_username(req, ctx, session, user_id, redirect_url)
option.Some("change-email") ->
handlers.handle_change_email(req, ctx, session, user_id, redirect_url)
option.Some("verify-email") ->
handlers.handle_verify_email(ctx, session, user_id, redirect_url)
option.Some("unlink-phone") ->
handlers.handle_unlink_phone(ctx, session, user_id, redirect_url)
option.Some("terminate-sessions") ->
handlers.handle_terminate_sessions(ctx, session, user_id, redirect_url)
option.Some("clear-fields") ->
handlers.handle_clear_fields(req, ctx, session, user_id, redirect_url)
option.Some("set-bot-status") ->
handlers.handle_set_bot_status(req, ctx, session, user_id, redirect_url)
option.Some("set-system-status") ->
handlers.handle_set_system_status(
req,
ctx,
session,
user_id,
redirect_url,
)
option.Some("temp-ban") ->
handlers.handle_temp_ban(req, ctx, session, user_id, redirect_url)
option.Some("unban") ->
handlers.handle_unban(ctx, session, user_id, redirect_url)
option.Some("schedule-deletion") ->
handlers.handle_schedule_deletion(
req,
ctx,
session,
user_id,
redirect_url,
)
option.Some("cancel-deletion") ->
handlers.handle_cancel_deletion(ctx, session, user_id, redirect_url)
option.Some("cancel-bulk-message-deletion") ->
handlers.handle_cancel_bulk_message_deletion(
ctx,
session,
user_id,
redirect_url,
)
option.Some("send-password-reset") ->
handlers.handle_send_password_reset(ctx, session, user_id, redirect_url)
option.Some("change-dob") ->
handlers.handle_change_dob(req, ctx, session, user_id, redirect_url)
option.Some("trigger-archive") ->
handle_trigger_archive(ctx, session, user_id, redirect_url)
option.Some("delete-all-messages") ->
handlers.handle_delete_all_messages(
req,
ctx,
session,
user_id,
redirect_url,
)
option.Some("message-shred") ->
handlers.handle_message_shred(req, ctx, session, user_id, redirect_url)
_ -> redirect(ctx, redirect_url)
}
}
fn archives_tab(ctx: Context, session: Session, user_id: String) {
let result =
archives.list_archives(ctx, session, "user", option.Some(user_id), False)
h.div([], [
ui.flex_row_between([
ui.heading_section("User Archives"),
h.form(
[
a.method("post"),
action(
ctx,
"/users/" <> user_id <> "?tab=archives&action=trigger-archive",
),
],
[
ui.button_primary("Trigger Archive", "submit", []),
],
),
]),
case result {
Ok(response) -> render_archive_table(ctx, response.archives)
Error(err) -> errors.api_error_view(ctx, err, option.None, option.None)
},
])
}
fn render_archive_table(ctx: Context, archives: List(archives.Archive)) {
case list.is_empty(archives) {
True ->
h.div(
[
a.class(
"mt-4 p-4 border border-dashed border-neutral-300 rounded-lg text-neutral-600",
),
],
[
element.text("No archives yet for this user."),
],
)
False ->
h.div(
[
a.class(
"mt-4 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([], [
h.th(
[
a.class(
"px-4 py-2 text-left text-xs font-medium text-neutral-700 uppercase tracking-wider",
),
],
[
element.text("Requested At"),
],
),
h.th(
[
a.class(
"px-4 py-2 text-left text-xs font-medium text-neutral-700 uppercase tracking-wider",
),
],
[
element.text("Status"),
],
),
h.th(
[
a.class(
"px-4 py-2 text-left text-xs font-medium text-neutral-700 uppercase tracking-wider",
),
],
[
element.text("Actions"),
],
),
]),
]),
h.tbody(
[a.class("divide-y divide-neutral-200")],
list.map(archives, fn(archive) {
h.tr([], [
h.td([a.class("px-4 py-3 text-sm text-neutral-900")], [
element.text(date_time.format_timestamp(
archive.requested_at,
)),
]),
h.td([a.class("px-4 py-3 text-sm text-neutral-900")], [
element.text(
status_text(archive)
<> " ("
<> int.to_string(archive.progress_percent)
<> "%)",
),
]),
h.td([a.class("px-4 py-3 text-sm")], [
case archive.completed_at {
option.Some(_) ->
h.a(
[
href(
ctx,
"/archives/download?subject_type=user&subject_id="
<> archive.subject_id
<> "&archive_id="
<> archive.archive_id,
),
a.class(
"text-sm text-white bg-neutral-900 hover:bg-neutral-800 px-3 py-1.5 rounded transition-colors",
),
],
[element.text("Download")],
)
option.None ->
h.span([a.class("text-neutral-500")], [
element.text("Pending"),
])
},
]),
])
}),
),
]),
],
)
}
}
fn status_text(archive: archives.Archive) -> String {
case archive.failed_at {
option.Some(_) -> "Failed"
option.None -> {
case archive.completed_at {
option.Some(_) -> "Completed"
option.None -> option.unwrap(archive.progress_step, "In Progress")
}
}
}
}
fn handle_trigger_archive(
ctx: Context,
session: Session,
user_id: String,
redirect_url: String,
) -> Response {
case archives.trigger_user_archive(ctx, session, user_id, option.None) {
Ok(_) -> redirect(ctx, redirect_url)
Error(err) ->
errors.api_error_view(
ctx,
err,
option.Some(redirect_url),
option.Some("Back"),
)
|> element.to_document_string
|> wisp.html_response(400)
}
}

View File

@@ -0,0 +1,263 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common
import fluxer_admin/api/users
import fluxer_admin/avatar
import fluxer_admin/badge
import fluxer_admin/components/errors
import fluxer_admin/components/flash
import fluxer_admin/components/layout
import fluxer_admin/components/pagination
import fluxer_admin/components/ui
import fluxer_admin/components/url_builder
import fluxer_admin/user
import fluxer_admin/web.{type Context, type Session, href}
import gleam/int
import gleam/list
import gleam/option
import gleam/string
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Response}
pub fn view(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
query: option.Option(String),
page: Int,
) -> Response {
let limit = 50
let offset = page * limit
let result = case query {
option.Some(q) ->
case string.trim(q) {
"" -> Ok(users.SearchUsersResponse(users: [], total: 0))
trimmed_query ->
users.search_users(ctx, session, trimmed_query, limit, offset)
}
option.None -> Ok(users.SearchUsersResponse(users: [], total: 0))
}
let content = case result {
Ok(response) -> {
h.div([a.class("max-w-7xl mx-auto space-y-6")], [
ui.flex_row_between([
ui.heading_page("Users"),
case query {
option.Some(_) ->
h.div([a.class("flex items-center gap-4")], [
h.span([a.class("text-sm text-neutral-600")], [
element.text(
"Found "
<> int.to_string(response.total)
<> " results (showing "
<> int.to_string(list.length(response.users))
<> ")",
),
]),
])
option.None -> element.none()
},
]),
render_search_form(ctx, query),
case query {
option.Some(_) ->
case list.is_empty(response.users) {
True -> empty_search_results()
False ->
h.div([], [
render_users_grid(ctx, response.users),
pagination.pagination(ctx, response.total, limit, page, fn(p) {
build_pagination_url(p, query)
}),
])
}
option.None -> empty_state()
},
])
}
Error(err) -> errors.api_error_view(ctx, err, option.None, option.None)
}
let html =
layout.page(
"Users",
"users",
ctx,
session,
current_admin,
flash_data,
content,
)
wisp.html_response(element.to_document_string(html), 200)
}
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.input([
a.type_("text"),
a.name("q"),
a.value(option.unwrap(query, "")),
a.placeholder("Search by ID, username, email, or phone..."),
a.class(
"flex-1 px-4 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:border-transparent",
),
a.attribute("autocomplete", "off"),
]),
ui.button_primary("Search", "submit", []),
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",
),
],
[element.text("Clear")],
),
]),
h.p([a.class("text-xs text-neutral-500")], [
element.text(
"Search supports: User ID, Username, Email, Phone number, and more",
),
]),
]),
])
}
fn render_users_grid(ctx: Context, users: List(common.UserLookupResult)) {
h.div(
[a.class("grid grid-cols-1 gap-4")],
list.map(users, fn(user) { render_user_card(ctx, user) }),
)
}
fn render_user_card(ctx: Context, user: common.UserLookupResult) {
let badges = badge.get_user_badges(ctx.cdn_endpoint, user.flags)
h.div(
[
a.class(
"bg-white border border-neutral-200 rounded-lg overflow-hidden hover:border-neutral-300 transition-colors",
),
],
[
h.div([a.class("p-5")], [
h.div([a.class("flex items-center gap-4")], [
h.img([
a.src(avatar.get_user_avatar_url(
ctx.media_endpoint,
ctx.cdn_endpoint,
user.id,
user.avatar,
True,
ctx.asset_version,
)),
a.alt(user.username),
a.class("w-16 h-16 rounded-full flex-shrink-0"),
]),
h.div([a.class("flex-1 min-w-0")], [
h.div([a.class("flex items-center gap-2 mb-1")], [
h.h2([a.class("text-base font-medium text-neutral-900")], [
element.text(
user.username
<> "#"
<> user.format_discriminator(user.discriminator),
),
]),
case user.bot {
True ->
h.span(
[
a.class("px-2 py-0.5 bg-blue-100 text-blue-700 rounded"),
],
[element.text("Bot")],
)
False -> element.none()
},
]),
case list.is_empty(badges) {
False ->
h.div(
[a.class("flex items-center gap-1.5 mb-2")],
list.map(badges, fn(b) {
h.img([
a.src(b.icon),
a.alt(b.name),
a.title(b.name),
a.class("w-5 h-5"),
])
}),
)
True -> element.none()
},
h.div([a.class("space-y-0.5")], [
h.div([a.class("text-sm text-neutral-600")], [
element.text("ID: " <> user.id),
]),
case user.extract_timestamp(user.id) {
Ok(created_at) ->
h.div([a.class("text-sm text-neutral-500")], [
element.text("Created: " <> created_at),
])
Error(_) -> element.none()
},
]),
]),
h.a(
[
href(ctx, "/users/" <> user.id),
a.class(
"px-4 py-2 bg-neutral-900 text-white rounded-lg text-sm font-medium hover:bg-neutral-800 transition-colors flex-shrink-0 no-underline",
),
],
[element.text("View Details")],
),
]),
]),
],
)
}
fn build_pagination_url(page: Int, query: option.Option(String)) -> String {
url_builder.build_url("/users", [
#("page", option.Some(int.to_string(page))),
#("q", query),
])
}
fn empty_state() {
ui.card_empty([
ui.text_muted("Enter a search query to find users"),
ui.text_small_muted(
"Search by User ID, Username, Email, Phone, or other attributes",
),
])
}
fn empty_search_results() {
ui.card_empty([
ui.text_muted("No users found"),
ui.text_small_muted("Try adjusting your search query"),
])
}

View File

@@ -0,0 +1,572 @@
//// Copyright (C) 2026 Fluxer Contributors
////
//// This file is part of Fluxer.
////
//// Fluxer is free software: you can redistribute it and/or modify
//// it under the terms of the GNU Affero General Public License as published by
//// the Free Software Foundation, either version 3 of the License, or
//// (at your option) any later version.
////
//// Fluxer is distributed in the hope that it will be useful,
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
//// GNU Affero General Public License for more details.
////
//// 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/api/common
import fluxer_admin/api/voice
import fluxer_admin/components/errors
import fluxer_admin/components/flash
import fluxer_admin/components/helpers
import fluxer_admin/components/layout
import fluxer_admin/components/ui
import fluxer_admin/components/voice as voice_components
import fluxer_admin/web.{type Context, type Session, href}
import gleam/float
import gleam/int
import gleam/list
import gleam/option
import gleam/result
import gleam/string
import lustre/attribute as a
import lustre/element
import lustre/element/html as h
import wisp.{type Request, type Response}
pub fn view(
ctx: Context,
session: Session,
current_admin: option.Option(common.UserLookupResult),
flash_data: option.Option(flash.Flash),
) -> Response {
let result = voice.list_voice_regions(ctx, session, True)
let content = case result {
Ok(response) ->
h.div([a.class("space-y-6")], [
ui.flex_row_between([
ui.heading_page("Voice Regions"),
h.a(
[
a.href("#create"),
a.class(
"px-4 py-2 bg-neutral-900 text-white rounded text-sm font-medium hover:bg-neutral-800 transition-colors",
),
],
[element.text("Create Region")],
),
]),
render_regions_list(ctx, response.regions),
h.div([a.id("create"), a.class("mt-8")], [render_create_form(ctx)]),
])
Error(err) -> errors.error_view(err)
}
let html =
layout.page(
"Voice Regions",
"voice-regions",
ctx,
session,
current_admin,
flash_data,
content,
)
wisp.html_response(element.to_document_string(html), 200)
}
fn render_regions_list(ctx: Context, regions: List(voice.VoiceRegion)) {
case list.is_empty(regions) {
True ->
ui.card_empty([
ui.text_muted("No voice regions configured yet."),
ui.text_small_muted("Create your first region to get started."),
])
False ->
h.div(
[a.class("space-y-4")],
list.map(regions, fn(r) { render_region_card(ctx, r) }),
)
}
}
fn render_region_card(ctx: Context, region: voice.VoiceRegion) {
h.div(
[a.class("bg-white border border-neutral-200 rounded-lg p-6 shadow-sm")],
[
h.div([a.class("flex items-start justify-between mb-4")], [
h.div([a.class("flex items-center gap-3")], [
h.span([a.class("text-3xl")], [element.text(region.emoji)]),
h.div([], [
h.h3([a.class("text-base font-semibold text-neutral-900")], [
element.text(region.name),
]),
h.p([a.class("text-sm text-neutral-600")], [
element.text("Region ID: " <> region.id),
]),
]),
]),
h.div([a.class("flex items-center gap-2 flex-wrap")], [
case region.is_default {
True ->
h.span(
[
a.class("px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded"),
],
[element.text("DEFAULT")],
)
False -> element.none()
},
voice_components.status_badges(
region.vip_only,
!list.is_empty(region.required_guild_features),
!list.is_empty(region.allowed_guild_ids),
),
]),
]),
h.div([a.class("grid grid-cols-2 md:grid-cols-3 gap-4 mb-4")], [
helpers.info_item("Latitude", float.to_string(region.latitude)),
helpers.info_item("Longitude", float.to_string(region.longitude)),
helpers.info_item("Servers", case region.servers {
option.Some(servers) -> int.to_string(list.length(servers))
option.None -> "0"
}),
]),
voice_components.features_list(region.required_guild_features),
voice_components.guild_ids_list(region.allowed_guild_ids),
case region.servers {
option.Some(servers) ->
case list.is_empty(servers) {
True -> element.none()
False ->
h.div([a.class("mt-4 pt-4 border-t border-neutral-200")], [
h.h4([a.class("text-sm font-medium text-neutral-700 mb-2")], [
element.text("Servers"),
]),
h.div(
[a.class("space-y-2")],
list.map(servers, fn(s) { render_server_row(ctx, s) }),
),
])
}
option.None -> element.none()
},
h.div([a.class("mt-4 flex gap-2")], [
h.form([a.method("POST"), a.action("?action=delete&id=" <> region.id)], [
h.button(
[
a.type_("submit"),
a.class(
"px-3 py-1.5 bg-red-600 text-white text-sm rounded hover:bg-red-700 transition-colors",
),
a.attribute(
"onclick",
"return confirm('Are you sure? This will delete all servers in this region.')",
),
],
[element.text("Delete Region")],
),
]),
h.a(
[
href(ctx, "/voice-servers?region=" <> region.id),
a.class(
"px-3 py-1.5 bg-neutral-100 text-neutral-900 text-sm rounded hover:bg-neutral-200 transition-colors",
),
],
[element.text("Manage Servers")],
),
]),
h.details([a.class("mt-6")], [
h.summary(
[
a.class(
"cursor-pointer px-4 py-2 bg-blue-50 text-blue-700 rounded hover:bg-blue-100 transition-colors text-sm font-medium",
),
],
[
element.text("Edit Region"),
],
),
h.div([a.class("mt-3 pt-3 border-t border-neutral-200")], [
render_edit_form(ctx, region),
]),
]),
],
)
}
fn render_server_row(_ctx: Context, server: voice.VoiceServer) {
h.div(
[a.class("flex items-center justify-between p-3 bg-neutral-50 rounded")],
[
h.div([a.class("flex-1")], [
h.p([a.class("text-sm text-neutral-900")], [
element.text(server.server_id),
]),
h.p([a.class("text-xs text-neutral-600")], [
element.text(server.endpoint),
]),
]),
h.div([a.class("flex items-center gap-2")], [
case server.is_active {
True ->
h.span(
[
a.class("px-2 py-1 bg-green-100 text-green-800 text-xs rounded"),
],
[element.text("ACTIVE")],
)
False ->
h.span(
[
a.class(
"px-2 py-1 bg-neutral-200 text-neutral-700 text-xs rounded",
),
],
[element.text("INACTIVE")],
)
},
]),
],
)
}
fn render_edit_form(_ctx: Context, region: voice.VoiceRegion) {
h.div([a.class("bg-neutral-50 rounded-lg p-4")], [
h.form(
[
a.method("POST"),
a.action("?action=update&id=" <> region.id),
a.class("space-y-3"),
],
[
h.div([a.class("grid grid-cols-1 md:grid-cols-2 gap-4")], [
helpers.form_field_with_value(
"Region Name",
"name",
"text",
region.name,
False,
"Display name for the region",
),
helpers.form_field_with_value(
"Emoji",
"emoji",
"text",
region.emoji,
False,
"Flag or emoji for the region",
),
helpers.form_field_with_value(
"Latitude",
"latitude",
"number",
float.to_string(region.latitude),
False,
"Geographic latitude",
),
helpers.form_field_with_value(
"Longitude",
"longitude",
"number",
float.to_string(region.longitude),
False,
"Geographic longitude",
),
]),
h.div([a.class("space-y-2")], [
h.label([a.class("flex items-center gap-2")], [
h.input([
a.type_("checkbox"),
a.name("is_default"),
a.value("true"),
a.checked(region.is_default),
]),
h.span([a.class("text-sm text-neutral-700")], [
element.text("Set as default region"),
]),
]),
]),
voice_components.restriction_fields(
region.vip_only,
region.required_guild_features,
region.allowed_guild_ids,
),
h.button(
[
a.type_("submit"),
a.class(
"w-full px-4 py-2 bg-blue-600 text-white rounded text-sm font-medium hover:bg-blue-700 transition-colors",
),
],
[element.text("Update Region")],
),
],
),
])
}
fn render_create_form(_ctx: Context) {
h.div([a.class("bg-white border border-neutral-200 rounded-lg p-6")], [
h.h2([a.class("text-base font-medium text-neutral-900 mb-4")], [
element.text("Create Voice Region"),
]),
h.form(
[a.method("POST"), a.action("?action=create"), a.class("space-y-4")],
[
h.div([a.class("grid grid-cols-1 md:grid-cols-2 gap-4")], [
helpers.form_field(
"Region ID",
"id",
"text",
"us-east",
True,
"Unique identifier for the region",
),
helpers.form_field(
"Region Name",
"name",
"text",
"US East",
True,
"Display name",
),
helpers.form_field(
"Emoji",
"emoji",
"text",
"🇺🇸",
True,
"Flag or emoji",
),
helpers.form_field(
"Latitude",
"latitude",
"number",
"40.7128",
True,
"Geographic latitude",
),
helpers.form_field(
"Longitude",
"longitude",
"number",
"-74.0060",
True,
"Geographic longitude",
),
]),
h.div([a.class("space-y-3")], [
h.label([a.class("flex items-center gap-2")], [
h.input([a.type_("checkbox"), a.name("is_default"), a.value("true")]),
h.span([a.class("text-sm text-neutral-700")], [
element.text("Set as default region"),
]),
]),
]),
voice_components.restriction_fields(False, [], []),
h.button(
[
a.type_("submit"),
a.class(
"w-full px-4 py-2 bg-neutral-900 text-white rounded text-sm font-medium hover:bg-neutral-800 transition-colors",
),
],
[element.text("Create Region")],
),
],
),
])
}
pub fn handle_action(req: Request, ctx: Context, session: Session) -> Response {
let query = wisp.get_query(req)
let action = list.key_find(query, "action") |> result.unwrap("unknown")
case action {
"create" -> handle_create(req, ctx, session)
"update" -> handle_update(req, ctx, session)
"delete" -> handle_delete(req, ctx, session)
_ -> wisp.redirect(web.prepend_base_path(ctx, "/voice-regions"))
}
}
fn handle_create(req: Request, ctx: Context, session: Session) -> Response {
use form_data <- wisp.require_form(req)
let id = list.key_find(form_data.values, "id") |> result.unwrap("")
let name = list.key_find(form_data.values, "name") |> result.unwrap("")
let emoji = list.key_find(form_data.values, "emoji") |> result.unwrap("")
let latitude_str =
list.key_find(form_data.values, "latitude") |> result.unwrap("0.0")
let longitude_str =
list.key_find(form_data.values, "longitude") |> result.unwrap("0.0")
let is_default_str =
list.key_find(form_data.values, "is_default") |> result.unwrap("false")
let vip_only_str =
list.key_find(form_data.values, "vip_only") |> result.unwrap("false")
let required_guild_features_str =
list.key_find(form_data.values, "required_guild_features")
|> result.unwrap("")
let allowed_guild_ids_str =
list.key_find(form_data.values, "allowed_guild_ids") |> result.unwrap("")
let latitude = float.parse(latitude_str) |> result.unwrap(0.0)
let longitude = float.parse(longitude_str) |> result.unwrap(0.0)
let is_default = is_default_str == "true"
let vip_only = vip_only_str == "true"
let required_guild_features =
string.split(required_guild_features_str, ",")
|> list.map(string.trim)
|> list.filter(fn(s) { !string.is_empty(s) })
let allowed_guild_ids =
string.split(allowed_guild_ids_str, ",")
|> list.map(string.trim)
|> list.filter(fn(s) { !string.is_empty(s) })
case
voice.create_voice_region(
ctx,
session,
id,
name,
emoji,
latitude,
longitude,
is_default,
vip_only,
required_guild_features,
allowed_guild_ids,
option.None,
)
{
Ok(_) ->
flash.redirect_with_success(
ctx,
"/voice-regions",
"Region created successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
"/voice-regions",
"Failed to create region",
)
}
}
fn handle_update(req: Request, ctx: Context, session: Session) -> Response {
use form_data <- wisp.require_form(req)
let query = wisp.get_query(req)
let id = list.key_find(query, "id") |> result.unwrap("")
let name = case list.key_find(form_data.values, "name") {
Ok(n) -> option.Some(n)
Error(_) -> option.None
}
let emoji = case list.key_find(form_data.values, "emoji") {
Ok(e) -> option.Some(e)
Error(_) -> option.None
}
let latitude_str = list.key_find(form_data.values, "latitude")
let longitude_str = list.key_find(form_data.values, "longitude")
let is_default_str =
list.key_find(form_data.values, "is_default") |> result.unwrap("false")
let vip_only_str =
list.key_find(form_data.values, "vip_only") |> result.unwrap("false")
let required_guild_features_str =
list.key_find(form_data.values, "required_guild_features")
|> result.unwrap("")
let allowed_guild_ids_str =
list.key_find(form_data.values, "allowed_guild_ids") |> result.unwrap("")
let latitude = case latitude_str {
Ok(s) ->
float.parse(s) |> result.map(option.Some) |> result.unwrap(option.None)
Error(_) -> option.None
}
let longitude = case longitude_str {
Ok(s) ->
float.parse(s) |> result.map(option.Some) |> result.unwrap(option.None)
Error(_) -> option.None
}
let is_default = case is_default_str {
"true" -> option.Some(True)
_ -> option.Some(False)
}
let vip_only = case vip_only_str {
"true" -> option.Some(True)
_ -> option.Some(False)
}
let required_guild_features =
string.split(required_guild_features_str, ",")
|> list.map(string.trim)
|> list.filter(fn(s) { !string.is_empty(s) })
|> option.Some
let allowed_guild_ids =
string.split(allowed_guild_ids_str, ",")
|> list.map(string.trim)
|> list.filter(fn(s) { !string.is_empty(s) })
|> option.Some
case
voice.update_voice_region(
ctx,
session,
id,
name,
emoji,
latitude,
longitude,
is_default,
vip_only,
required_guild_features,
allowed_guild_ids,
option.None,
)
{
Ok(_) ->
flash.redirect_with_success(
ctx,
"/voice-regions",
"Region updated successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
"/voice-regions",
"Failed to update region",
)
}
}
fn handle_delete(req: Request, ctx: Context, session: Session) -> Response {
let query = wisp.get_query(req)
let id = list.key_find(query, "id") |> result.unwrap("")
case voice.delete_voice_region(ctx, session, id, option.None) {
Ok(_) ->
flash.redirect_with_success(
ctx,
"/voice-regions",
"Region deleted successfully",
)
Error(_) ->
flash.redirect_with_error(
ctx,
"/voice-regions",
"Failed to delete region",
)
}
}

Some files were not shown because too many files have changed in this diff Show More