Skip to main content

rustio_core/
admin.rs

1//! Auto-generated CRUD admin backed by [`crate::orm`].
2//!
3//! Build an [`Admin`] by chaining `.model::<T>()` calls, then mount it with
4//! [`Admin::register`]. This attaches list / create / edit / delete routes
5//! at `/admin/<admin_name>` for each model and an index page at `/admin`
6//! listing every registered model.
7//!
8//! For a single-model app, [`register`] is a convenience wrapper.
9
10use std::sync::Arc;
11
12use bytes::Bytes;
13use http_body_util::{BodyExt, Full};
14
15use crate::error::Error;
16use crate::http::{html, Request, Response};
17use crate::orm::{Db, Model};
18use crate::router::Router;
19
20// `FormData` lives in `http` and is re-exported here so that the
21// `#[derive(RustioAdmin)]`-generated code referencing
22// `::rustio_core::admin::FormData` continues to work.
23pub use crate::http::FormData;
24
25#[derive(Debug, Clone, Copy)]
26pub enum FieldType {
27    I32,
28    I64,
29    String,
30    Bool,
31}
32
33#[derive(Debug, Clone, Copy)]
34pub struct AdminField {
35    pub name: &'static str,
36    pub ty: FieldType,
37    pub editable: bool,
38}
39
40pub trait AdminModel: Model {
41    const ADMIN_NAME: &'static str;
42    const DISPLAY_NAME: &'static str;
43    const FIELDS: &'static [AdminField];
44
45    fn field_display(&self, name: &str) -> Option<String>;
46    fn from_form(form: &FormData, id: Option<i64>) -> Result<Self, Error>;
47
48    /// Singular form of the display name. Used for labels like "New X" and
49    /// "Edit X". Defaults to [`DISPLAY_NAME`]; the `#[derive(RustioAdmin)]`
50    /// macro generates a proper singular form.
51    fn singular_name() -> &'static str {
52        Self::DISPLAY_NAME
53    }
54}
55
56/// Metadata about one registered admin model.
57#[derive(Debug, Clone)]
58pub struct AdminEntry {
59    pub admin_name: &'static str,
60    pub display_name: &'static str,
61    pub singular_name: &'static str,
62}
63
64type ModelRegistrar = Box<dyn FnOnce(Router, &Db) -> Router + Send + Sync>;
65
66/// Builder that collects admin models and mounts them with a shared
67/// `/admin` index page.
68///
69/// ```no_run
70/// use rustio_core::admin::Admin;
71/// # use rustio_core::{Db, Router};
72/// # fn demo(router: Router, db: &Db) -> Router {
73/// # struct Post; struct User;
74/// # impl rustio_core::Model for Post {
75/// #   const TABLE: &'static str = "posts";
76/// #   const COLUMNS: &'static [&'static str] = &[];
77/// #   const INSERT_COLUMNS: &'static [&'static str] = &[];
78/// #   fn id(&self) -> i64 { 0 }
79/// #   fn from_row(_: rustio_core::Row<'_>) -> Result<Self, rustio_core::Error> { unimplemented!() }
80/// #   fn insert_values(&self) -> Vec<rustio_core::Value> { vec![] }
81/// # }
82/// # impl rustio_core::Model for User {
83/// #   const TABLE: &'static str = "users";
84/// #   const COLUMNS: &'static [&'static str] = &[];
85/// #   const INSERT_COLUMNS: &'static [&'static str] = &[];
86/// #   fn id(&self) -> i64 { 0 }
87/// #   fn from_row(_: rustio_core::Row<'_>) -> Result<Self, rustio_core::Error> { unimplemented!() }
88/// #   fn insert_values(&self) -> Vec<rustio_core::Value> { vec![] }
89/// # }
90/// # impl rustio_core::admin::AdminModel for Post {
91/// #   const ADMIN_NAME: &'static str = "posts"; const DISPLAY_NAME: &'static str = "Posts";
92/// #   const FIELDS: &'static [rustio_core::admin::AdminField] = &[];
93/// #   fn field_display(&self, _: &str) -> Option<String> { None }
94/// #   fn from_form(_: &rustio_core::admin::FormData, _: Option<i64>) -> Result<Self, rustio_core::Error> { unimplemented!() }
95/// # }
96/// # impl rustio_core::admin::AdminModel for User {
97/// #   const ADMIN_NAME: &'static str = "users"; const DISPLAY_NAME: &'static str = "Users";
98/// #   const FIELDS: &'static [rustio_core::admin::AdminField] = &[];
99/// #   fn field_display(&self, _: &str) -> Option<String> { None }
100/// #   fn from_form(_: &rustio_core::admin::FormData, _: Option<i64>) -> Result<Self, rustio_core::Error> { unimplemented!() }
101/// # }
102/// Admin::new()
103///     .model::<Post>()
104///     .model::<User>()
105///     .register(router, db)
106/// # }
107/// ```
108pub struct Admin {
109    entries: Vec<AdminEntry>,
110    registrars: Vec<ModelRegistrar>,
111}
112
113impl Admin {
114    pub fn new() -> Self {
115        Self {
116            entries: Vec::new(),
117            registrars: Vec::new(),
118        }
119    }
120
121    /// Register a model on this admin. Adds its metadata to the index
122    /// and queues its CRUD routes for mounting.
123    pub fn model<T: AdminModel>(mut self) -> Self {
124        self.entries.push(AdminEntry {
125            admin_name: T::ADMIN_NAME,
126            display_name: T::DISPLAY_NAME,
127            singular_name: T::singular_name(),
128        });
129        self.registrars
130            .push(Box::new(|router, db| mount_model::<T>(router, db)));
131        self
132    }
133
134    /// Number of registered models.
135    pub fn len(&self) -> usize {
136        self.entries.len()
137    }
138
139    pub fn is_empty(&self) -> bool {
140        self.entries.is_empty()
141    }
142
143    /// Metadata for inspection.
144    pub fn entries(&self) -> &[AdminEntry] {
145        &self.entries
146    }
147
148    /// Mount the admin onto a router: installs `/admin` (index) and
149    /// CRUD routes for every registered model. Admin-only; handlers
150    /// return 401/403 via [`require_admin`].
151    pub fn register(self, mut router: Router, db: &Db) -> Router {
152        let entries = Arc::new(self.entries);
153        let index_entries = entries.clone();
154        router = router.get("/admin", move |req, _params| {
155            let entries = index_entries.clone();
156            async move {
157                if let Err(resp) = admin_guard(req.ctx()) {
158                    return Ok(resp);
159                }
160                Ok::<Response, Error>(html(admin_layout("Admin", &index_page(&entries))))
161            }
162        });
163
164        // Login + logout routes. These run without admin_guard: unauthenticated
165        // users *need* to reach /admin/login, and logout should work from any
166        // state (idempotent cookie expiry).
167        router = router.post("/admin/login", |req, _params| async move {
168            handle_login(req).await
169        });
170        router = router.post("/admin/logout", |_req, _params| async move {
171            Ok::<Response, Error>(handle_logout())
172        });
173
174        for registrar in self.registrars {
175            router = registrar(router, db);
176        }
177        router
178    }
179}
180
181impl Default for Admin {
182    fn default() -> Self {
183        Self::new()
184    }
185}
186
187/// Convenience: mount CRUD routes and an `/admin` index for a single model.
188/// Equivalent to `Admin::new().model::<T>().register(router, db)`.
189pub fn register<T>(router: Router, db: &Db) -> Router
190where
191    T: AdminModel + Model,
192{
193    Admin::new().model::<T>().register(router, db)
194}
195
196fn mount_model<T>(mut router: Router, db: &Db) -> Router
197where
198    T: AdminModel + Model,
199{
200    let base = format!("/admin/{}", T::ADMIN_NAME);
201    let create_path = format!("{base}/create");
202    let edit_path = format!("{base}/:id/edit");
203    let delete_path = format!("{base}/:id/delete");
204
205    let list_db = db.clone();
206    router = router.get(&base, move |req, _params| {
207        let db = list_db.clone();
208        async move {
209            if let Err(resp) = admin_guard(req.ctx()) {
210                return Ok(resp);
211            }
212            let items = T::all(&db).await?;
213            Ok::<Response, Error>(html(admin_layout(T::DISPLAY_NAME, &list_page::<T>(&items))))
214        }
215    });
216
217    router = router.get(&create_path, |req, _params| async move {
218        if let Err(resp) = admin_guard(req.ctx()) {
219            return Ok(resp);
220        }
221        Ok::<Response, Error>(html(admin_layout(
222            &format!("New {}", T::DISPLAY_NAME),
223            &form_page::<T>(None, &format!("/admin/{}/create", T::ADMIN_NAME)),
224        )))
225    });
226
227    let create_db = db.clone();
228    router = router.post(&create_path, move |req, _params| {
229        let db = create_db.clone();
230        async move {
231            if let Err(resp) = admin_guard(req.ctx()) {
232                return Ok(resp);
233            }
234            let form = read_form(req).await?;
235            let item = T::from_form(&form, None)?;
236            item.create(&db).await?;
237            Ok::<Response, Error>(redirect(&format!("/admin/{}", T::ADMIN_NAME)))
238        }
239    });
240
241    let edit_db = db.clone();
242    router = router.get(&edit_path, move |req, params| {
243        let db = edit_db.clone();
244        async move {
245            if let Err(resp) = admin_guard(req.ctx()) {
246                return Ok(resp);
247            }
248            let id = parse_id_param(&params)?;
249            let item = T::find(&db, id).await?.ok_or(Error::NotFound)?;
250            Ok::<Response, Error>(html(admin_layout(
251                &format!("Edit {}", T::DISPLAY_NAME),
252                &form_page::<T>(
253                    Some(&item),
254                    &format!("/admin/{}/{}/edit", T::ADMIN_NAME, id),
255                ),
256            )))
257        }
258    });
259
260    let update_db = db.clone();
261    router = router.post(&edit_path, move |req, params| {
262        let db = update_db.clone();
263        async move {
264            if let Err(resp) = admin_guard(req.ctx()) {
265                return Ok(resp);
266            }
267            let id = parse_id_param(&params)?;
268            let form = read_form(req).await?;
269            let item = T::from_form(&form, Some(id))?;
270            item.update(&db).await?;
271            Ok::<Response, Error>(redirect(&format!("/admin/{}", T::ADMIN_NAME)))
272        }
273    });
274
275    let delete_db = db.clone();
276    router = router.post(&delete_path, move |req, params| {
277        let db = delete_db.clone();
278        async move {
279            if let Err(resp) = admin_guard(req.ctx()) {
280                return Ok(resp);
281            }
282            let id = parse_id_param(&params)?;
283            T::delete(&db, id).await?;
284            Ok::<Response, Error>(redirect(&format!("/admin/{}", T::ADMIN_NAME)))
285        }
286    });
287
288    router
289}
290
291fn parse_id_param(params: &crate::router::Params) -> Result<i64, Error> {
292    params
293        .get("id")
294        .and_then(|s| s.parse::<i64>().ok())
295        .ok_or_else(|| Error::BadRequest(String::from("invalid id")))
296}
297
298async fn read_form(req: Request) -> Result<FormData, Error> {
299    let (_, body, _) = req.into_parts();
300    let collected = body
301        .collect()
302        .await
303        .map_err(|e| Error::BadRequest(e.to_string()))?
304        .to_bytes();
305    let body_str = std::str::from_utf8(&collected).map_err(|e| Error::BadRequest(e.to_string()))?;
306    Ok(FormData::parse(body_str))
307}
308
309fn redirect(to: &str) -> Response {
310    hyper::Response::builder()
311        .status(303)
312        .header("location", to)
313        .body(Full::new(Bytes::new()))
314        .expect("valid redirect")
315}
316
317fn admin_layout(title: &str, content: &str) -> String {
318    format!(
319        r#"<!doctype html>
320<html lang="en">
321<head>
322<meta charset="utf-8">
323<meta name="viewport" content="width=device-width, initial-scale=1">
324<title>{title} — RustIO Admin</title>
325<style>{css}</style>
326</head>
327<body>
328<header>
329<h1><a href="/admin">RustIO Admin</a></h1>
330<form method="post" action="/admin/logout" class="header-logout">
331<button type="submit">Sign out</button>
332</form>
333</header>
334<main>{content}</main>
335</body>
336</html>"#,
337        title = escape_html(title),
338        css = ADMIN_CSS,
339        content = content,
340    )
341}
342
343fn index_page(entries: &[AdminEntry]) -> String {
344    if entries.is_empty() {
345        return String::from(
346            r#"<h2>Admin</h2>
347<p class="empty">No models are registered. Add one with
348<code>Admin::new().model::&lt;YourModel&gt;()</code> or scaffold an app
349via <code>rustio new app &lt;name&gt;</code>.</p>"#,
350        );
351    }
352    let rows: String = entries
353        .iter()
354        .map(|e| {
355            format!(
356                r#"<li><a href="/admin/{name}"><span class="label">{display}</span><span class="path">/admin/{name}</span></a></li>"#,
357                name = escape_html(e.admin_name),
358                display = escape_html(e.display_name),
359            )
360        })
361        .collect();
362    format!(
363        r#"<h2>Admin</h2>
364<ul class="admin-index">{rows}</ul>"#
365    )
366}
367
368fn list_page<T: AdminModel>(items: &[T]) -> String {
369    let headers: String = T::FIELDS
370        .iter()
371        .map(|f| format!("<th>{}</th>", escape_html(f.name)))
372        .collect();
373    let rows: String = items
374        .iter()
375        .map(|item| {
376            let cells: String = T::FIELDS
377                .iter()
378                .map(|f| {
379                    let v = item.field_display(f.name).unwrap_or_default();
380                    format!("<td>{}</td>", escape_html(&v))
381                })
382                .collect();
383            let id = item.id();
384            let actions = format!(
385                r#"<td class="actions">
386<a href="/admin/{name}/{id}/edit">edit</a>
387<form method="post" action="/admin/{name}/{id}/delete">
388<button type="submit" class="danger">delete</button>
389</form>
390</td>"#,
391                name = T::ADMIN_NAME,
392                id = id,
393            );
394            format!("<tr>{cells}{actions}</tr>")
395        })
396        .collect();
397
398    format!(
399        r#"<div class="toolbar">
400<h2>{title}</h2>
401<a class="button" href="/admin/{name}/create">New {singular}</a>
402</div>
403<table>
404<thead><tr>{headers}<th>actions</th></tr></thead>
405<tbody>{rows}</tbody>
406</table>"#,
407        title = escape_html(T::DISPLAY_NAME),
408        singular = escape_html(T::singular_name()),
409        name = T::ADMIN_NAME,
410    )
411}
412
413fn form_page<T: AdminModel>(item: Option<&T>, action: &str) -> String {
414    let fields: String = T::FIELDS
415        .iter()
416        .filter(|f| f.editable)
417        .map(|f| render_field::<T>(f, item))
418        .collect();
419    let heading = if item.is_some() {
420        format!("Edit {}", T::singular_name())
421    } else {
422        format!("New {}", T::singular_name())
423    };
424    format!(
425        r#"<h2>{heading}</h2>
426<form method="post" action="{action}">
427{fields}
428<div class="form-actions">
429<button type="submit">Save</button>
430<a class="cancel" href="/admin/{name}">Cancel</a>
431</div>
432</form>"#,
433        heading = escape_html(&heading),
434        action = escape_html(action),
435        name = T::ADMIN_NAME,
436    )
437}
438
439fn render_field<T: AdminModel>(f: &AdminField, item: Option<&T>) -> String {
440    let current = item
441        .and_then(|i| i.field_display(f.name))
442        .unwrap_or_default();
443    let input = match f.ty {
444        FieldType::Bool => format!(
445            r#"<input type="checkbox" name="{n}" {checked}>"#,
446            n = escape_html(f.name),
447            checked = if current == "true" { "checked" } else { "" },
448        ),
449        FieldType::I32 | FieldType::I64 => format!(
450            r#"<input type="number" name="{n}" value="{v}">"#,
451            n = escape_html(f.name),
452            v = escape_html(&current),
453        ),
454        FieldType::String => format!(
455            r#"<input type="text" name="{n}" value="{v}">"#,
456            n = escape_html(f.name),
457            v = escape_html(&current),
458        ),
459    };
460    format!(
461        r#"<label><span>{label}</span>{input}</label>"#,
462        label = escape_html(f.name),
463        input = input,
464    )
465}
466
467/// Gate every admin request behind [`require_admin`], but convert the
468/// resulting `Error::Unauthorized` / `Error::Forbidden` into a friendly
469/// HTML response instead of the default `text/plain` body.
470///
471/// Returns `Ok(())` when the caller is admin and should continue.
472/// Returns `Err(Response)` with a ready-made HTML error page otherwise.
473//
474// `Response` is moderately large; clippy's `result_large_err` would
475// rather we box it. Each call site only constructs one per request and
476// the call count per request is at most one, so the nominal size cost
477// is irrelevant — boxing would only add noise.
478#[allow(clippy::result_large_err)]
479fn admin_guard(ctx: &crate::context::Context) -> Result<(), Response> {
480    // 401 renders a login form (so browser users can actually sign in).
481    // 403 renders a static "forbidden" page (the user is authenticated
482    // but not admin — there's nothing to type into a form that would
483    // change that).
484    match crate::auth::require_admin(ctx) {
485        Ok(_) => Ok(()),
486        Err(Error::Unauthorized) => Err(login_page(401, None)),
487        Err(Error::Forbidden) => Err(forbidden_page()),
488        Err(other) => Err(other.into_response()),
489    }
490}
491
492/// Render the login page. Status is 401 on a pure auth-gate hit and
493/// 400 on failed submissions (with `error` set to an explanation).
494fn login_page(status: u16, error: Option<&str>) -> Response {
495    let error_html = match error {
496        Some(msg) => format!(r#"<p class="error">{}</p>"#, escape_html(msg)),
497        None => String::new(),
498    };
499
500    // In production the dev-token hint is suppressed. The form is still
501    // shown — the developer might have wired a different `authenticate`
502    // middleware that accepts other tokens via the same cookie.
503    let hint = if crate::auth::in_production() {
504        String::new()
505    } else {
506        String::from(
507            r#"<p class="hint">Development tokens: <code>dev-admin</code> (full access) · <code>dev-user</code> (non-admin, to preview 403).</p>"#,
508        )
509    };
510
511    let body = format!(
512        r#"<!doctype html>
513<html lang="en">
514<head>
515<meta charset="utf-8">
516<meta name="viewport" content="width=device-width, initial-scale=1">
517<title>Sign in — RustIO Admin</title>
518<style>{css}</style>
519</head>
520<body>
521<header><h1><a href="/admin">RustIO Admin</a></h1></header>
522<main class="auth-card">
523<h2>Sign in</h2>
524<form method="post" action="/admin/login" autocomplete="off">
525<label><span>Token</span>
526<input type="password" name="token" autofocus required>
527</label>
528{error}
529<button type="submit">Sign in</button>
530</form>
531{hint}
532</main>
533</body>
534</html>"#,
535        css = ADMIN_CSS,
536        error = error_html,
537        hint = hint,
538    );
539
540    hyper::Response::builder()
541        .status(status)
542        .header("content-type", "text/html; charset=utf-8")
543        .body(Full::new(Bytes::from(body)))
544        .expect("valid response")
545}
546
547fn forbidden_page() -> Response {
548    let body = format!(
549        r#"<!doctype html>
550<html lang="en">
551<head>
552<meta charset="utf-8">
553<meta name="viewport" content="width=device-width, initial-scale=1">
554<title>403 Forbidden — RustIO Admin</title>
555<style>{css}</style>
556</head>
557<body>
558<header><h1><a href="/admin">RustIO Admin</a></h1></header>
559<main class="auth-error">
560<div class="status">403</div>
561<p class="heading">Forbidden</p>
562<p class="hint">You are signed in but your account isn't an admin.</p>
563<form method="post" action="/admin/logout" class="inline">
564<button type="submit" class="secondary">Sign out</button>
565</form>
566</main>
567</body>
568</html>"#,
569        css = ADMIN_CSS,
570    );
571
572    hyper::Response::builder()
573        .status(403)
574        .header("content-type", "text/html; charset=utf-8")
575        .body(Full::new(Bytes::from(body)))
576        .expect("valid response")
577}
578
579/// `POST /admin/login` — validate the submitted token against
580/// `authenticate`'s dev token mapping (when RUSTIO_ENV != production)
581/// and set the `rustio_token` cookie so subsequent requests carry it
582/// automatically.
583async fn handle_login(req: Request) -> Result<Response, Error> {
584    let form = read_form(req).await?;
585    let token = form.get("token").unwrap_or("").trim().to_string();
586
587    if token.is_empty() {
588        return Ok(login_page(400, Some("Token is required.")));
589    }
590
591    // Production mode disables dev tokens entirely. A real deployment
592    // would replace `authenticate` with its own middleware that recognises
593    // production tokens via the same cookie; this branch is the safe
594    // default when no such middleware is installed.
595    if crate::auth::in_production() {
596        return Ok(login_page(
597            401,
598            Some("Sign-in is disabled in production until a real auth middleware is installed."),
599        ));
600    }
601
602    if crate::auth::dev_identity(&token).is_none() {
603        return Ok(login_page(401, Some("That token is not recognised.")));
604    }
605
606    // Success: drop a cookie scoped to the site and redirect to /admin.
607    // HttpOnly prevents JS read (XSS hardening), SameSite=Strict prevents
608    // cross-site submission (CSRF hardening for this cookie).
609    let mut resp = redirect("/admin");
610    crate::http::set_cookie(
611        &mut resp,
612        &format!("rustio_token={token}; Path=/; HttpOnly; SameSite=Strict"),
613    );
614    Ok(resp)
615}
616
617/// `POST /admin/logout` — clear the cookie and send the user back to
618/// `/admin` (which will re-render the login page).
619fn handle_logout() -> Response {
620    let mut resp = redirect("/admin");
621    crate::http::set_cookie(
622        &mut resp,
623        "rustio_token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0",
624    );
625    resp
626}
627
628fn escape_html(s: &str) -> String {
629    let mut out = String::with_capacity(s.len());
630    for ch in s.chars() {
631        match ch {
632            '&' => out.push_str("&amp;"),
633            '<' => out.push_str("&lt;"),
634            '>' => out.push_str("&gt;"),
635            '"' => out.push_str("&quot;"),
636            '\'' => out.push_str("&#39;"),
637            c => out.push(c),
638        }
639    }
640    out
641}
642
643const ADMIN_CSS: &str = r#"
644*, *::before, *::after { box-sizing: border-box; }
645body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
646  background: #fafafa; color: #222; margin: 0; }
647header { background: #222; color: white; padding: 1rem 2rem; display: flex; align-items: center; justify-content: space-between; }
648header h1 { margin: 0; font-size: 1.1rem; font-weight: 600; letter-spacing: 0.02em; }
649header h1 a { color: inherit; text-decoration: none; }
650header h1 a:hover { opacity: 0.9; }
651header .header-logout { margin: 0; }
652header .header-logout button { background: transparent; color: #d8d8dc; border: 1px solid #444; padding: 0.35rem 0.75rem; font-size: 0.85rem; border-radius: 4px; cursor: pointer; }
653header .header-logout button:hover { background: #2f2f33; color: white; }
654ul.admin-index { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.5rem; }
655ul.admin-index li { background: white; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
656ul.admin-index li a { display: flex; justify-content: space-between; align-items: center; padding: 0.9rem 1.1rem; text-decoration: none; color: #222; }
657ul.admin-index li a:hover { background: #f4f4f5; }
658ul.admin-index li .label { font-weight: 600; }
659ul.admin-index li .path { color: #888; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85rem; }
660p.empty { color: #666; }
661p.empty code { background: #f0f0f2; padding: 0.1rem 0.35rem; border-radius: 3px; font-size: 0.9em; }
662.auth-error { text-align: center; padding: 3rem 2rem; max-width: 36rem; margin: 0 auto; }
663.auth-error .status { font-size: 3rem; font-weight: 700; color: #b42318; line-height: 1; }
664.auth-error .heading { font-size: 1.15rem; margin: 0.5rem 0 1.5rem; font-weight: 600; color: #222; }
665.auth-error .hint { background: #fff7ed; border: 1px solid #fed7aa; color: #9a3412; padding: 0.85rem 1rem; border-radius: 6px; text-align: left; font-size: 0.92rem; line-height: 1.5; }
666.auth-error .hint code { background: #fdefe0; color: #7c2d12; padding: 0.1rem 0.35rem; border-radius: 3px; font-size: 0.9em; display: inline-block; }
667.auth-error form.inline { margin-top: 1rem; }
668.auth-error form.inline button.secondary { background: transparent; border: 1px solid #d0d0d4; color: #222; }
669.auth-card { max-width: 22rem; margin: 2.5rem auto; padding: 2rem; background: white; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.05); }
670.auth-card h2 { margin: 0 0 1.25rem; font-size: 1.25rem; }
671.auth-card label { display: block; margin: 0 0 0.9rem; }
672.auth-card label span { display: block; font-weight: 500; margin-bottom: 0.25rem; font-size: 0.9rem; }
673.auth-card input[type=password] { width: 100%; padding: 0.55rem 0.75rem; border: 1px solid #d0d0d4; border-radius: 4px; font: inherit; }
674.auth-card button[type=submit] { width: 100%; padding: 0.6rem 1rem; background: #222; color: white; border: none; border-radius: 4px; font: inherit; cursor: pointer; }
675.auth-card button[type=submit]:hover { background: #000; }
676.auth-card .error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; padding: 0.6rem 0.8rem; border-radius: 4px; margin: 0 0 0.9rem; font-size: 0.9rem; }
677.auth-card .hint { color: #666; font-size: 0.85rem; margin: 1rem 0 0; line-height: 1.5; }
678.auth-card .hint code { background: #f0f0f2; padding: 0.1rem 0.3rem; border-radius: 3px; font-size: 0.85em; }
679main { padding: 2rem; max-width: 60rem; margin: 0 auto; }
680h2 { margin: 0; }
681.toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; }
682table { border-collapse: collapse; width: 100%; background: white; border-radius: 6px; overflow: hidden;
683  box-shadow: 0 1px 3px rgba(0,0,0,0.04); }
684th, td { text-align: left; padding: 0.6rem 0.9rem; border-bottom: 1px solid #eee; font-size: 0.95rem; }
685th { background: #f4f4f5; font-weight: 600; }
686tbody tr:last-child td { border-bottom: none; }
687td.actions { display: flex; gap: 0.5rem; align-items: center; }
688td.actions form { margin: 0; display: inline; }
689a { color: #0366d6; text-decoration: none; }
690a:hover { text-decoration: underline; }
691label { display: block; margin-bottom: 1rem; }
692label span { display: block; font-weight: 500; margin-bottom: 0.25rem; font-size: 0.9rem; }
693input[type=text], input[type=number] { padding: 0.5rem 0.75rem; border: 1px solid #d0d0d4;
694  border-radius: 4px; width: 24rem; max-width: 100%; font: inherit; }
695input[type=checkbox] { transform: scale(1.1); }
696button, .button { padding: 0.5rem 1rem; background: #222; color: white; border: none;
697  border-radius: 4px; cursor: pointer; font: inherit; text-decoration: none; display: inline-block; }
698button:hover, .button:hover { background: #000; text-decoration: none; }
699button.danger { background: #b42318; }
700button.danger:hover { background: #8a1c12; }
701.form-actions { display: flex; gap: 0.5rem; align-items: center; margin-top: 1rem; }
702.form-actions .cancel { color: #666; }
703"#;
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708
709    #[test]
710    fn escape_html_escapes_dangerous_chars() {
711        assert_eq!(
712            escape_html("<script>alert(\"xss\")</script>"),
713            "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;"
714        );
715        assert_eq!(escape_html("a & b"), "a &amp; b");
716        assert_eq!(escape_html("it's"), "it&#39;s");
717    }
718}