glorifying admin panel

This commit is contained in:
onysd 2026-09-08 01:53:49 +03:00
parent ae2cc3ba90
commit 87173ae43f
12 changed files with 335 additions and 20 deletions

View file

@ -0,0 +1,64 @@
package main
import (
"net/http"
)
// The login screen shows which server it belongs to, so the two fields it needs
// are served without a session.
//
// This discloses nothing new. owpengram-server already publishes the same name
// and icon to every client that asks, over its own open endpoints
// (/owpengram/server-info and /owpengram/server-icon) -- that is how a client
// fills in the "Add Server" form and how the panel's own RefreshServersInfo
// works. Branding is public by design; the point of a server name is to be
// read before you are anybody.
//
// It stays deliberately narrow all the same: name and whether an icon exists,
// and nothing else off identity.Info, which also carries the description and
// the welcome/login-code message templates. Those are operator-facing settings
// and stay behind server.manage.
type publicBrandingResponse struct {
Name string `json:"name"`
HasIcon bool `json:"has_icon"`
}
func (s *server) handlePublicBrandingAPI(w http.ResponseWriter, _ *http.Request) {
out := publicBrandingResponse{}
if s.identity != nil {
if info, err := s.identity.Get(); err == nil {
out.Name = info.Name
}
_, _, ok := s.identity.Icon()
out.HasIcon = ok
}
// A server that has not been named yet answers with an empty name rather
// than an error: the login page falls back to its own branding, and a
// failed request there would only produce a console error for nothing.
writeJSON(w, http.StatusOK, out)
}
// handlePublicIconAPI serves the same bytes as handleServerIconAPI, without the
// session. Same file, same rationale as above.
func (s *server) handlePublicIconAPI(w http.ResponseWriter, _ *http.Request) {
if s.identity == nil {
http.NotFound(w, nil)
return
}
data, ext, ok := s.identity.Icon()
if !ok {
http.Error(w, "no icon configured", http.StatusNotFound)
return
}
contentType := map[string]string{
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".webp": "image/webp", ".gif": "image/gif",
}[ext]
if contentType == "" {
contentType = "application/octet-stream"
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(data)
}

View file

@ -30,9 +30,15 @@ func TestEveryAPIRouteDeclaresAScope(t *testing.T) {
"s.botVerificationRead(",
"s.botVerificationManage(",
}
// /api/login is the way in, so it is authenticated by the credential it
// carries rather than by a session that does not exist yet.
exempt := map[string]bool{"POST /api/login": true}
// Routes that are reachable before a session exists, each for a stated
// reason: /api/login is the way in (it carries its own credential), and the
// two branding routes feed the login screen with the server name and icon
// that owpengram-server already publishes to every client.
exempt := map[string]bool{
"POST /api/login": true,
"GET /api/public/branding": true,
"GET /api/public/icon": true,
}
route := regexp.MustCompile(`mux\.Handle(Func)?\("([A-Z]+ /api/[^"]*)"`)

View file

@ -64,6 +64,12 @@ func (s *server) routes() http.Handler {
// logout is a state change, and an invalid session is cleared by the gate
// itself, so nothing is stranded by protecting it.
mux.Handle("POST /api/logout", s.scopedRoute(permissionSessionOnly, http.HandlerFunc(s.handleAPILogout)))
// Unauthenticated on purpose: the login screen shows which server it is,
// and the name/icon are already public from owpengram-server's own client
// endpoints. See publicbranding.go.
mux.HandleFunc("GET /api/public/branding", s.handlePublicBrandingAPI)
mux.HandleFunc("GET /api/public/icon", s.handlePublicIconAPI)
mux.Handle("GET /api/session", s.scopedRoute(permissionSessionOnly, http.HandlerFunc(s.handleSession)))
// Operator accounts. Every one of these is gated on admins.manage -- the

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -23,8 +23,8 @@
})();
</script>
<script type="module" crossorigin src="/assets/index-C1sjJr8N.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CHW7aAwX.css">
<script type="module" crossorigin src="/assets/index-C9AiUiug.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-qygZBefQ.css">
</head>
<body>
<div id="root"></div>

View file

@ -1,4 +1,5 @@
import type {
PublicBranding,
AdminConsoleUserList,
AccountDetail,
AccountListResponse,
@ -145,6 +146,9 @@ export function errorMessage(error: unknown): string {
export const api = {
session: () => request<AdminSession>("/api/session"),
// Reachable before login: the sign-in screen says which server it belongs to.
publicBranding: () => request<PublicBranding>("/api/public/branding"),
publicIconURL: () => `/api/public/icon?t=${Date.now()}`,
// The built-in operator is named "owpengram" and is checked against the
// configured TELESRV_ADMIN_UI_PASSWORD / _TOKEN -- the break-glass login
// that still works when the database is unreachable. A blank username is

View file

@ -0,0 +1,164 @@
import { useEffect, useRef } from "react";
// The drifting icon field from the marketing site (owpengram-site's
// BgIcons.vue), ported so the console's sign-in screen looks like the same
// product rather than a different one.
//
// Same 28 particles, same sizes, speeds, opacities and repulsion as the
// original -- this is meant to match, not to be a second interpretation of the
// idea.
// Inner markup for each icon, drawn as strokes on a 24x24 viewBox. These are
// source constants, never anything a user supplied, which is what makes
// injecting them as HTML below safe.
const ICON_PATHS = [
`<rect x="2.5" y="3.5" width="19" height="12" rx="2"/><path d="M8 20h8M12 15.5V20"/>`,
`<rect x="3.5" y="4" width="17" height="6" rx="2"/><rect x="3.5" y="14" width="17" height="6" rx="2"/><path d="M7 7h.01M7 17h.01"/>`,
`<rect x="3" y="5" width="18" height="14" rx="2.5"/><path d="M3 7l9 6.5L21 7"/>`,
`<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3c2.6 2.5 4 5.7 4 9s-1.4 6.5-4 9c-2.6-2.5-4-5.7-4-9s1.4-6.5 4-9z"/>`,
`<rect x="7" y="3" width="10" height="18" rx="2.4"/><path d="M11 18h2"/>`,
`<path d="M8 6l-6 6 6 6M16 6l6 6-6 6"/>`,
`<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M18.5 15.5l.8 2 2 .8-2 .8-.8 2-.8-2-2-.8 2-.8z"/>`,
`<path d="M12 3l7 3v5c0 4.5-3 7.6-7 9-4-1.4-7-4.5-7-9V6l7-3z"/><path d="M8.8 12.2l2.1 2.1 4.3-4.3"/>`,
`<path d="M21.5 3.5l-19 7.5 5.5 2.5 3 5 3-2 5.5 5z"/><path d="M11 14l8.5-8.5"/>`,
`<rect x="4.5" y="11" width="15" height="9" rx="2.2"/><path d="M8 11V8a4 4 0 0 1 7.5-1.9"/><path d="M12 15v2"/>`,
`<path d="M12 3v12"/><path d="M7.5 10.5L12 15l4.5-4.5"/><path d="M5 20h14"/>`,
`<circle cx="12" cy="12" r="2.5"/><path d="M12 2v4M12 18v4M2 12h4M18 12h4M4.9 4.9l2.8 2.8M16.3 16.3l2.8 2.8M4.9 19.1l2.8-2.8M16.3 7.7l2.8-2.8"/>`,
`<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>`,
`<path d="M12 2l3.1 6.3L22 9.5l-5 4.9 1.2 7L12 17.3 5.8 21.4 7 14.4l-5-4.9 6.9-1.2z"/>`,
`<path d="M14.5 17.5L19 13l-4.5-4.5M9.5 6.5L5 11l4.5 4.5"/>`,
`<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>`,
`<path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/><path d="M15 12h4M17 10v4"/>`,
`<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>`,
`<circle cx="12" cy="12" r="2"/><path d="M12 2v4M12 18v4M2 12h4M18 12h4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>`,
`<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>`,
`<path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z"/>`,
`<rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/>`,
`<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>`,
`<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/>`,
`<circle cx="12" cy="12" r="10"/><path d="M12 6v12M6 12h12"/>`
];
const PARTICLE_COUNT = 28;
type Particle = {
x: number;
y: number;
vx: number;
vy: number;
size: number;
rot: number;
rotSpeed: number;
};
export function BgIcons() {
const hostRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
// Someone who asked the system for less motion gets a still field rather
// than twenty-eight things drifting across their screen.
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
return;
}
const nodes = Array.from(host.querySelectorAll<SVGSVGElement>(".bg-icon"));
let particles: Particle[] = [];
function seed() {
const w = window.innerWidth;
const h = window.innerHeight;
const margin = 60;
particles = nodes.map((node) => {
const size = 22 + Math.random() * 30;
node.style.width = `${size}px`;
node.style.height = `${size}px`;
node.style.opacity = String(0.18 + Math.random() * 0.12);
return {
x: margin + Math.random() * Math.max(1, w - margin * 2),
y: margin + Math.random() * Math.max(1, h - margin * 2),
vx: (Math.random() - 0.5) * 0.3,
vy: (Math.random() - 0.5) * 0.3,
size,
rot: Math.random() * 360,
rotSpeed: (Math.random() - 0.5) * 0.08
};
});
}
let frame = 0;
function tick() {
const w = window.innerWidth;
const h = window.innerHeight;
for (let i = 0; i < particles.length; i++) {
const a = particles[i];
a.x += a.vx;
a.y += a.vy;
a.rot += a.rotSpeed;
// Bounce off the viewport edges so the field stays on screen.
const half = a.size / 2;
if (a.x < half) { a.x = half; a.vx *= -1; }
if (a.x > w - half) { a.x = w - half; a.vx *= -1; }
if (a.y < half) { a.y = half; a.vy *= -1; }
if (a.y > h - half) { a.y = h - half; a.vy *= -1; }
// Gentle mutual repulsion: without it the drift eventually clumps the
// icons into a corner and the field stops reading as a field.
for (let j = i + 1; j < particles.length; j++) {
const b = particles[j];
const dx = b.x - a.x;
const dy = b.y - a.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const minDist = (a.size + b.size) / 2 + 20;
if (dist < minDist && dist > 0.01) {
const force = ((minDist - dist) / minDist) * 0.02;
const nx = dx / dist;
const ny = dy / dist;
a.vx -= nx * force;
a.vy -= ny * force;
b.vx += nx * force;
b.vy += ny * force;
}
}
}
// Written straight to the nodes: this runs every frame, and putting 28
// positions through React state would re-render the sign-in form sixty
// times a second to move some background art.
for (let i = 0; i < nodes.length; i++) {
const p = particles[i];
nodes[i].style.transform = `translate(${p.x}px, ${p.y}px) rotate(${p.rot}deg)`;
}
frame = requestAnimationFrame(tick);
}
seed();
frame = requestAnimationFrame(tick);
window.addEventListener("resize", seed);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", seed);
};
}, []);
return (
<div className="bg-icons" aria-hidden="true" ref={hostRef}>
{Array.from({ length: PARTICLE_COUNT }, (_, i) => (
<svg
key={i}
className="bg-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
dangerouslySetInnerHTML={{ __html: ICON_PATHS[i % ICON_PATHS.length] }}
/>
))}
</div>
);
}

View file

@ -1,9 +1,10 @@
import type { FormEvent } from "react";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { api, errorMessage } from "../api";
import { BgIcons } from "../components/BgIcons";
import { Alert } from "../components/ui";
import { ThemeSwitch } from "../theme";
import type { AdminSession } from "../types";
import type { AdminSession, PublicBranding } from "../types";
export function LoginPage({ onLogin }: { onLogin: (session: AdminSession) => void }) {
// Deliberately not pre-filled: the built-in operator is a default name, not a
@ -13,6 +14,41 @@ export function LoginPage({ onLogin }: { onLogin: (session: AdminSession) => voi
const [secret, setSecret] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
// Whose server this is. Fetched without a session -- the name and icon are
// already public from owpengram-server's client endpoints -- and left null on
// failure so the panel simply keeps its own branding.
const [branding, setBranding] = useState<PublicBranding | null>(null);
const [iconFailed, setIconFailed] = useState(false);
const orbsRef = useRef<HTMLDivElement>(null);
useEffect(() => {
api.publicBranding().then(setBranding).catch(() => undefined);
}, []);
// The orbs drift with the pointer, the same 30px parallax the marketing site
// uses. Written straight to the node instead of through state: this fires on
// every mouse move, and re-rendering the whole form for a background offset
// would be a lot of work to move three blurred circles.
useEffect(() => {
// Someone who asked the system for less motion gets none of this: the CSS
// drift is already disabled for them, and a JS-driven transform would walk
// straight past that preference.
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
return;
}
function onMove(event: MouseEvent) {
const node = orbsRef.current;
if (!node) return;
const x = (event.clientX / window.innerWidth - 0.5) * 30;
const y = (event.clientY / window.innerHeight - 0.5) * 30;
node.style.transform = `translate(${x}px, ${y}px)`;
}
window.addEventListener("mousemove", onMove);
return () => window.removeEventListener("mousemove", onMove);
}, []);
const serverName = branding?.name?.trim() || "OwpenGram";
const iconSrc = branding?.has_icon && !iconFailed ? api.publicIconURL() : "/logo.png";
async function submit(event: FormEvent) {
event.preventDefault();
@ -32,17 +68,20 @@ export function LoginPage({ onLogin }: { onLogin: (session: AdminSession) => voi
return (
<main className="login-page">
<div className="bg-orbs" aria-hidden="true">
<div className="bg-orbs" aria-hidden="true" ref={orbsRef}>
<div className="bg-orb bg-orb--1" />
<div className="bg-orb bg-orb--2" />
<div className="bg-orb bg-orb--3" />
</div>
<BgIcons />
<section className="login-panel">
<div className="login-head">
<div className="brand brand-elevated">
<span className="brand-mark"><img src="/logo.png" alt="OwpenGram" /></span>
<span className="brand-mark">
<img src={iconSrc} alt={serverName} onError={() => setIconFailed(true)} />
</span>
<span>
<strong>OwpenGram</strong>
<strong>{serverName}</strong>
<small>{"Admin Console"}</small>
</span>
</div>

View file

@ -277,6 +277,10 @@
inset: -60px;
z-index: 0;
pointer-events: none;
/* Eased so the pointer parallax glides instead of snapping to the cursor,
matching the marketing site. will-change keeps it off the main thread. */
transition: transform 0.3s ease-out;
will-change: transform;
}
.login-page .bg-orb {
@ -338,6 +342,7 @@
@media (prefers-reduced-motion: reduce) {
.login-page .bg-orb { animation: none; }
.login-page .bg-orbs { transition: none; }
}
.login-page .login-panel {
@ -483,3 +488,23 @@
text-overflow: ellipsis;
white-space: nowrap;
}
/* The drifting icon field behind the sign-in card. Sits above the blurred orbs
and below the panel, so the two background layers read as one scene.
Positioned absolutely inside .login-page rather than fixed like the site's
version, because here it belongs to one screen rather than the whole app. */
.login-page .bg-icons {
position: absolute;
inset: 0;
z-index: 0;
overflow: hidden;
color: var(--brand-2);
pointer-events: none;
}
.login-page .bg-icon {
position: absolute;
top: 0;
left: 0;
will-change: transform;
}

View file

@ -937,3 +937,10 @@ export type AdminConsoleUserList = {
// from what the routes actually enforce.
available_permissions: string[];
};
// The two branding fields the login screen may read without a session.
export type PublicBranding = {
name: string;
has_icon: boolean;
};