initial commit
This commit is contained in:
48
fluxer_admin/Dockerfile
Normal file
48
fluxer_admin/Dockerfile
Normal 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"]
|
||||
21
fluxer_admin/Dockerfile.dev
Normal file
21
fluxer_admin/Dockerfile.dev
Normal 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
21
fluxer_admin/gleam.toml
Normal 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
34
fluxer_admin/justfile
Normal 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
|
||||
55
fluxer_admin/manifest.toml
Normal file
55
fluxer_admin/manifest.toml
Normal 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" }
|
||||
0
fluxer_admin/priv/static/.gitkeep
Normal file
0
fluxer_admin/priv/static/.gitkeep
Normal file
72
fluxer_admin/src/fluxer_admin.gleam
Normal file
72
fluxer_admin/src/fluxer_admin.gleam
Normal 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
|
||||
}
|
||||
24
fluxer_admin/src/fluxer_admin/acl.gleam
Normal file
24
fluxer_admin/src/fluxer_admin/acl.gleam
Normal 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)
|
||||
}
|
||||
264
fluxer_admin/src/fluxer_admin/api/archives.gleam
Normal file
264
fluxer_admin/src/fluxer_admin/api/archives.gleam
Normal 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)
|
||||
}
|
||||
}
|
||||
128
fluxer_admin/src/fluxer_admin/api/assets.gleam
Normal file
128
fluxer_admin/src/fluxer_admin/api/assets.gleam
Normal 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)
|
||||
}
|
||||
}
|
||||
169
fluxer_admin/src/fluxer_admin/api/audit.gleam
Normal file
169
fluxer_admin/src/fluxer_admin/api/audit.gleam
Normal 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)
|
||||
}
|
||||
}
|
||||
249
fluxer_admin/src/fluxer_admin/api/bans.gleam
Normal file
249
fluxer_admin/src/fluxer_admin/api/bans.gleam
Normal 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)
|
||||
}
|
||||
}
|
||||
332
fluxer_admin/src/fluxer_admin/api/bulk.gleam
Normal file
332
fluxer_admin/src/fluxer_admin/api/bulk.gleam
Normal 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)
|
||||
}
|
||||
}
|
||||
124
fluxer_admin/src/fluxer_admin/api/codes.gleam
Normal file
124
fluxer_admin/src/fluxer_admin/api/codes.gleam
Normal 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)
|
||||
}
|
||||
}
|
||||
239
fluxer_admin/src/fluxer_admin/api/common.gleam
Normal file
239
fluxer_admin/src/fluxer_admin/api/common.gleam
Normal 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,
|
||||
))
|
||||
}
|
||||
109
fluxer_admin/src/fluxer_admin/api/feature_flags.gleam
Normal file
109
fluxer_admin/src/fluxer_admin/api/feature_flags.gleam
Normal 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)
|
||||
}
|
||||
}
|
||||
182
fluxer_admin/src/fluxer_admin/api/guild_assets.gleam
Normal file
182
fluxer_admin/src/fluxer_admin/api/guild_assets.gleam
Normal 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)
|
||||
}
|
||||
}
|
||||
529
fluxer_admin/src/fluxer_admin/api/guilds.gleam
Normal file
529
fluxer_admin/src/fluxer_admin/api/guilds.gleam
Normal 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)
|
||||
}
|
||||
}
|
||||
191
fluxer_admin/src/fluxer_admin/api/guilds_members.gleam
Normal file
191
fluxer_admin/src/fluxer_admin/api/guilds_members.gleam
Normal 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)
|
||||
}
|
||||
}
|
||||
172
fluxer_admin/src/fluxer_admin/api/instance_config.gleam
Normal file
172
fluxer_admin/src/fluxer_admin/api/instance_config.gleam
Normal 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)
|
||||
}
|
||||
}
|
||||
508
fluxer_admin/src/fluxer_admin/api/messages.gleam
Normal file
508
fluxer_admin/src/fluxer_admin/api/messages.gleam
Normal 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)
|
||||
}
|
||||
}
|
||||
264
fluxer_admin/src/fluxer_admin/api/metrics.gleam
Normal file
264
fluxer_admin/src/fluxer_admin/api/metrics.gleam
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1010
fluxer_admin/src/fluxer_admin/api/oauth.gleam
Normal file
1010
fluxer_admin/src/fluxer_admin/api/oauth.gleam
Normal file
File diff suppressed because it is too large
Load Diff
705
fluxer_admin/src/fluxer_admin/api/reports.gleam
Normal file
705
fluxer_admin/src/fluxer_admin/api/reports.gleam
Normal 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)
|
||||
}
|
||||
}
|
||||
202
fluxer_admin/src/fluxer_admin/api/search.gleam
Normal file
202
fluxer_admin/src/fluxer_admin/api/search.gleam
Normal 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)
|
||||
}
|
||||
}
|
||||
247
fluxer_admin/src/fluxer_admin/api/system.gleam
Normal file
247
fluxer_admin/src/fluxer_admin/api/system.gleam
Normal 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)
|
||||
}
|
||||
}
|
||||
700
fluxer_admin/src/fluxer_admin/api/users.gleam
Normal file
700
fluxer_admin/src/fluxer_admin/api/users.gleam
Normal 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)
|
||||
}
|
||||
}
|
||||
160
fluxer_admin/src/fluxer_admin/api/verifications.gleam
Normal file
160
fluxer_admin/src/fluxer_admin/api/verifications.gleam
Normal 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)),
|
||||
])
|
||||
}
|
||||
567
fluxer_admin/src/fluxer_admin/api/voice.gleam
Normal file
567
fluxer_admin/src/fluxer_admin/api/voice.gleam
Normal 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,
|
||||
)
|
||||
}
|
||||
138
fluxer_admin/src/fluxer_admin/avatar.gleam
Normal file
138
fluxer_admin/src/fluxer_admin/avatar.gleam
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
73
fluxer_admin/src/fluxer_admin/badge.gleam
Normal file
73
fluxer_admin/src/fluxer_admin/badge.gleam
Normal 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
|
||||
}
|
||||
}
|
||||
61
fluxer_admin/src/fluxer_admin/components/date_time.gleam
Normal file
61
fluxer_admin/src/fluxer_admin/components/date_time.gleam
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
108
fluxer_admin/src/fluxer_admin/components/errors.gleam
Normal file
108
fluxer_admin/src/fluxer_admin/components/errors.gleam
Normal 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()
|
||||
},
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
}
|
||||
152
fluxer_admin/src/fluxer_admin/components/flash.gleam
Normal file
152
fluxer_admin/src/fluxer_admin/components/flash.gleam
Normal 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()
|
||||
}
|
||||
}
|
||||
106
fluxer_admin/src/fluxer_admin/components/helpers.gleam
Normal file
106
fluxer_admin/src/fluxer_admin/components/helpers.gleam
Normal 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)]),
|
||||
])
|
||||
}
|
||||
157
fluxer_admin/src/fluxer_admin/components/icons.gleam
Normal file
157
fluxer_admin/src/fluxer_admin/components/icons.gleam
Normal 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"),
|
||||
],
|
||||
[],
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
46
fluxer_admin/src/fluxer_admin/components/icons_meta.gleam
Normal file
46
fluxer_admin/src/fluxer_admin/components/icons_meta.gleam
Normal 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"),
|
||||
]),
|
||||
]
|
||||
}
|
||||
392
fluxer_admin/src/fluxer_admin/components/layout.gleam
Normal file
392
fluxer_admin/src/fluxer_admin/components/layout.gleam
Normal 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"),
|
||||
])
|
||||
}
|
||||
180
fluxer_admin/src/fluxer_admin/components/message_list.gleam
Normal file
180
fluxer_admin/src/fluxer_admin/components/message_list.gleam
Normal 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>"
|
||||
}
|
||||
93
fluxer_admin/src/fluxer_admin/components/pagination.gleam
Normal file
93
fluxer_admin/src/fluxer_admin/components/pagination.gleam
Normal 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 →")],
|
||||
)
|
||||
},
|
||||
])
|
||||
}
|
||||
419
fluxer_admin/src/fluxer_admin/components/review_deck.gleam
Normal file
419
fluxer_admin/src/fluxer_admin/components/review_deck.gleam
Normal 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();
|
||||
}
|
||||
})();
|
||||
"
|
||||
}
|
||||
@@ -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,
|
||||
]),
|
||||
],
|
||||
)
|
||||
}
|
||||
101
fluxer_admin/src/fluxer_admin/components/review_keyboard.gleam
Normal file
101
fluxer_admin/src/fluxer_admin/components/review_keyboard.gleam
Normal 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 });
|
||||
})();
|
||||
"
|
||||
}
|
||||
99
fluxer_admin/src/fluxer_admin/components/search_form.gleam
Normal file
99
fluxer_admin/src/fluxer_admin/components/search_form.gleam
Normal 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()
|
||||
},
|
||||
]),
|
||||
])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
45
fluxer_admin/src/fluxer_admin/components/tabs.gleam
Normal file
45
fluxer_admin/src/fluxer_admin/components/tabs.gleam
Normal 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)])
|
||||
}
|
||||
662
fluxer_admin/src/fluxer_admin/components/ui.gleam
Normal file
662
fluxer_admin/src/fluxer_admin/components/ui.gleam
Normal 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),
|
||||
]),
|
||||
])
|
||||
}
|
||||
56
fluxer_admin/src/fluxer_admin/components/url_builder.gleam
Normal file
56
fluxer_admin/src/fluxer_admin/components/url_builder.gleam
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
243
fluxer_admin/src/fluxer_admin/components/voice.gleam
Normal file
243
fluxer_admin/src/fluxer_admin/components/voice.gleam
Normal 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)]) }),
|
||||
),
|
||||
]),
|
||||
])
|
||||
}
|
||||
}
|
||||
151
fluxer_admin/src/fluxer_admin/config.gleam
Normal file
151
fluxer_admin/src/fluxer_admin/config.gleam
Normal 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(""),
|
||||
))
|
||||
}
|
||||
562
fluxer_admin/src/fluxer_admin/constants.gleam
Normal file
562
fluxer_admin/src/fluxer_admin/constants.gleam
Normal 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,
|
||||
]
|
||||
}
|
||||
31
fluxer_admin/src/fluxer_admin/log.gleam
Normal file
31
fluxer_admin/src/fluxer_admin/log.gleam
Normal 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", [])
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
20
fluxer_admin/src/fluxer_admin/mode.gleam
Normal file
20
fluxer_admin/src/fluxer_admin/mode.gleam
Normal 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"
|
||||
}
|
||||
42
fluxer_admin/src/fluxer_admin/oauth2.gleam
Normal file
42
fluxer_admin/src/fluxer_admin/oauth2.gleam
Normal 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)
|
||||
}
|
||||
233
fluxer_admin/src/fluxer_admin/pages/archives_page.gleam
Normal file
233
fluxer_admin/src/fluxer_admin/pages/archives_page.gleam
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
244
fluxer_admin/src/fluxer_admin/pages/asset_purge_page.gleam
Normal file
244
fluxer_admin/src/fluxer_admin/pages/asset_purge_page.gleam
Normal 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.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
683
fluxer_admin/src/fluxer_admin/pages/audit_logs_page.gleam
Normal file
683
fluxer_admin/src/fluxer_admin/pages/audit_logs_page.gleam
Normal 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")],
|
||||
)
|
||||
},
|
||||
],
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
279
fluxer_admin/src/fluxer_admin/pages/ban_management_page.gleam
Normal file
279
fluxer_admin/src/fluxer_admin/pages/ban_management_page.gleam
Normal 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))
|
||||
}
|
||||
}
|
||||
336
fluxer_admin/src/fluxer_admin/pages/beta_codes_page.gleam
Normal file
336
fluxer_admin/src/fluxer_admin/pages/beta_codes_page.gleam
Normal 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"
|
||||
}
|
||||
}
|
||||
736
fluxer_admin/src/fluxer_admin/pages/bulk_actions_page.gleam
Normal file
736
fluxer_admin/src/fluxer_admin/pages/bulk_actions_page.gleam
Normal 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"))
|
||||
}
|
||||
}
|
||||
53
fluxer_admin/src/fluxer_admin/pages/email_bans_page.gleam
Normal file
53
fluxer_admin/src/fluxer_admin/pages/email_bans_page.gleam
Normal 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,
|
||||
)
|
||||
}
|
||||
237
fluxer_admin/src/fluxer_admin/pages/feature_flags_page.gleam
Normal file
237
fluxer_admin/src/fluxer_admin/pages/feature_flags_page.gleam
Normal 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",
|
||||
)
|
||||
}
|
||||
}
|
||||
916
fluxer_admin/src/fluxer_admin/pages/gateway_page.gleam
Normal file
916
fluxer_admin/src/fluxer_admin/pages/gateway_page.gleam
Normal 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),
|
||||
]),
|
||||
],
|
||||
)
|
||||
}
|
||||
393
fluxer_admin/src/fluxer_admin/pages/gift_codes_page.gleam
Normal file
393
fluxer_admin/src/fluxer_admin/pages/gift_codes_page.gleam
Normal 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"
|
||||
}
|
||||
}
|
||||
183
fluxer_admin/src/fluxer_admin/pages/guild_detail/forms.gleam
Normal file
183
fluxer_admin/src/fluxer_admin/pages/guild_detail/forms.gleam
Normal 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')",
|
||||
),
|
||||
)
|
||||
}
|
||||
535
fluxer_admin/src/fluxer_admin/pages/guild_detail/handlers.gleam
Normal file
535
fluxer_admin/src/fluxer_admin/pages/guild_detail/handlers.gleam
Normal 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.",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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."),
|
||||
]),
|
||||
])
|
||||
}
|
||||
@@ -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)],
|
||||
)
|
||||
})
|
||||
})
|
||||
},
|
||||
])
|
||||
},
|
||||
])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
])
|
||||
}
|
||||
@@ -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)],
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
])
|
||||
}
|
||||
@@ -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."),
|
||||
]),
|
||||
])
|
||||
}
|
||||
566
fluxer_admin/src/fluxer_admin/pages/guild_detail_page.gleam
Normal file
566
fluxer_admin/src/fluxer_admin/pages/guild_detail_page.gleam
Normal 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)
|
||||
}
|
||||
}
|
||||
266
fluxer_admin/src/fluxer_admin/pages/guilds_page.gleam
Normal file
266
fluxer_admin/src/fluxer_admin/pages/guilds_page.gleam
Normal 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"),
|
||||
])
|
||||
}
|
||||
345
fluxer_admin/src/fluxer_admin/pages/instance_config_page.gleam
Normal file
345
fluxer_admin/src/fluxer_admin/pages/instance_config_page.gleam
Normal 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",
|
||||
)
|
||||
}
|
||||
}
|
||||
53
fluxer_admin/src/fluxer_admin/pages/ip_bans_page.gleam
Normal file
53
fluxer_admin/src/fluxer_admin/pages/ip_bans_page.gleam
Normal 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,
|
||||
)
|
||||
}
|
||||
602
fluxer_admin/src/fluxer_admin/pages/jobs_page.gleam
Normal file
602
fluxer_admin/src/fluxer_admin/pages/jobs_page.gleam
Normal 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>';
|
||||
}
|
||||
})();
|
||||
"
|
||||
}
|
||||
83
fluxer_admin/src/fluxer_admin/pages/login_page.gleam
Normal file
83
fluxer_admin/src/fluxer_admin/pages/login_page.gleam
Normal 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)
|
||||
}
|
||||
572
fluxer_admin/src/fluxer_admin/pages/messages_metrics_page.gleam
Normal file
572
fluxer_admin/src/fluxer_admin/pages/messages_metrics_page.gleam
Normal 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);
|
||||
}
|
||||
})();
|
||||
"
|
||||
}
|
||||
523
fluxer_admin/src/fluxer_admin/pages/messages_page.gleam
Normal file
523
fluxer_admin/src/fluxer_admin/pages/messages_page.gleam
Normal 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"))
|
||||
}
|
||||
}
|
||||
873
fluxer_admin/src/fluxer_admin/pages/metrics_page.gleam
Normal file
873
fluxer_admin/src/fluxer_admin/pages/metrics_page.gleam
Normal 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)) <> "%"
|
||||
}
|
||||
235
fluxer_admin/src/fluxer_admin/pages/oauth2_callback_page.gleam
Normal file
235
fluxer_admin/src/fluxer_admin/pages/oauth2_callback_page.gleam
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
}
|
||||
53
fluxer_admin/src/fluxer_admin/pages/phone_bans_page.gleam
Normal file
53
fluxer_admin/src/fluxer_admin/pages/phone_bans_page.gleam
Normal 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,
|
||||
)
|
||||
}
|
||||
657
fluxer_admin/src/fluxer_admin/pages/report_detail_page.gleam
Normal file
657
fluxer_admin/src/fluxer_admin/pages/report_detail_page.gleam
Normal 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)]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
}
|
||||
1180
fluxer_admin/src/fluxer_admin/pages/reports_page.gleam
Normal file
1180
fluxer_admin/src/fluxer_admin/pages/reports_page.gleam
Normal file
File diff suppressed because it is too large
Load Diff
313
fluxer_admin/src/fluxer_admin/pages/search_index_page.gleam
Normal file
313
fluxer_admin/src/fluxer_admin/pages/search_index_page.gleam
Normal 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
|
||||
}
|
||||
}
|
||||
640
fluxer_admin/src/fluxer_admin/pages/storage_page.gleam
Normal file
640
fluxer_admin/src/fluxer_admin/pages/storage_page.gleam
Normal 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);
|
||||
}
|
||||
})();
|
||||
"
|
||||
}
|
||||
220
fluxer_admin/src/fluxer_admin/pages/user_detail/forms.gleam
Normal file
220
fluxer_admin/src/fluxer_admin/pages/user_detail/forms.gleam
Normal 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)],
|
||||
),
|
||||
])
|
||||
}
|
||||
827
fluxer_admin/src/fluxer_admin/pages/user_detail/handlers.gleam
Normal file
827
fluxer_admin/src/fluxer_admin/pages/user_detail/handlers.gleam
Normal 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
|
||||
}
|
||||
@@ -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")],
|
||||
),
|
||||
],
|
||||
),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
}
|
||||
@@ -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")],
|
||||
),
|
||||
]),
|
||||
]),
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
608
fluxer_admin/src/fluxer_admin/pages/user_detail_page.gleam
Normal file
608
fluxer_admin/src/fluxer_admin/pages/user_detail_page.gleam
Normal 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)
|
||||
}
|
||||
}
|
||||
263
fluxer_admin/src/fluxer_admin/pages/users_page.gleam
Normal file
263
fluxer_admin/src/fluxer_admin/pages/users_page.gleam
Normal 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"),
|
||||
])
|
||||
}
|
||||
572
fluxer_admin/src/fluxer_admin/pages/voice_regions_page.gleam
Normal file
572
fluxer_admin/src/fluxer_admin/pages/voice_regions_page.gleam
Normal 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
Reference in New Issue
Block a user