# Flash Messages + Announcement Banners Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Add reusable cookie-based flash messages and env-var-driven announcement banners to the web-api portal so users see errors, confirmations, and maintenance notices.

**Architecture:** Flash codes are set as a short-lived HttpOnly cookie on redirect, read and cleared on the next page render via an Axum extractor. Announcement text comes from an env var read at startup. Both render in the base template above page content.

**Tech Stack:** Axum 0.8 (headers, cookies), askama 0.15 templates, existing design tokens

---

### Task 1: Flash module -- message types and code mapping

**Files:**
- Create: `crates/web-api/src/flash.rs`

**Step 1: Create the flash module**

```rust
use axum::http::header::SET_COOKIE;
use axum::http::{HeaderMap, HeaderValue};

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum FlashLevel {
    Success,
    Error,
}

#[derive(Debug, Clone)]
pub struct FlashMessage {
    pub level: FlashLevel,
    pub text: &'static str,
}

pub fn resolve(code: &str) -> Option<FlashMessage> {
    let (level, text) = match code {
        "login_failed" => (FlashLevel::Error, "login failed. please try again."),
        "bouncer_created" => (FlashLevel::Success, "bouncer created."),
        "bouncer_create_failed" => (FlashLevel::Error, "failed to create bouncer."),
        "profile_saved" => (FlashLevel::Success, "profile saved."),
        "profile_save_failed" => (FlashLevel::Error, "failed to save profile."),
        "email_update_failed" => (FlashLevel::Error, "failed to update email in identity provider."),
        _ => return None,
    };
    Some(FlashMessage { level, text })
}

pub fn set_flash(code: &str) -> (axum::http::HeaderName, HeaderValue) {
    let cookie = format!(
        "flash={code}; Path=/; Max-Age=10; HttpOnly; SameSite=Lax; Secure"
    );
    (SET_COOKIE, HeaderValue::from_str(&cookie).unwrap())
}

pub fn clear_flash() -> (axum::http::HeaderName, HeaderValue) {
    (
        SET_COOKIE,
        HeaderValue::from_static(
            "flash=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax; Secure"
        ),
    )
}

pub fn read_flash(headers: &HeaderMap) -> Option<FlashMessage> {
    headers
        .get_all("cookie")
        .iter()
        .filter_map(|v| v.to_str().ok())
        .flat_map(|s| s.split(';'))
        .map(|s| s.trim())
        .find_map(|pair| {
            let (k, v) = pair.split_once('=')?;
            if k.trim() == "flash" {
                resolve(v.trim())
            } else {
                None
            }
        })
}
```

**Step 2: Register the module**

In `crates/web-api/src/main.rs`, add `mod flash;` to the module declarations.

**Step 3: Commit**

```
feat(web-api): add flash message module with cookie-based set/read/clear
```

---

### Task 2: Announcement banner on AppState

**Files:**
- Modify: `crates/web-api/src/state.rs`
- Modify: `crates/web-api/src/main.rs`

**Step 1: Add announcement field to AppState**

In `crates/web-api/src/state.rs`, add to the struct:

```rust
pub announcement: Option<String>,
```

**Step 2: Read env var in main.rs**

In `crates/web-api/src/main.rs`, before constructing `AppState`:

```rust
let announcement = std::env::var("ANNOUNCEMENT").ok().filter(|s| !s.is_empty());
```

And add `announcement` to the `AppState { ... }` construction.

**Step 3: Commit**

```
feat(web-api): read ANNOUNCEMENT env var into AppState
```

---

### Task 3: Base template -- flash and announcement rendering

**Files:**
- Modify: `crates/web-api/templates/base.html`
- Modify: `crates/web-api/static/portal.css`

**Step 1: Add flash and announcement blocks to base.html**

After the `</nav>` line (line 32) and before `{% block content %}`, add:

```html
    {% block banner %}{% endblock %}
    {% block flash %}{% endblock %}
```

**Step 2: Add CSS for flash messages and announcement banners**

Append to `crates/web-api/static/portal.css`:

```css
.flash {
  font-family: var(--mono);
  font-size: 12px;
  padding: 10px 18px;
  border-radius: var(--radius-lg);
  margin-top: 14px;
}

.flash-success {
  background: var(--g-pale);
  border: 1px solid var(--g);
  color: var(--g2);
}

.flash-error {
  background: #2E1C1C;
  border: 1px solid #AA5555;
  color: #E08080;
}

.announcement {
  font-family: var(--mono);
  font-size: 12px;
  padding: 10px 18px;
  background: #2A2818;
  border: 1px solid #AA9E68;
  border-radius: var(--radius-lg);
  color: #D4C878;
  margin-top: 14px;
}
```

**Step 3: Commit**

```
feat(web-api): add flash and announcement CSS and template blocks
```

---

### Task 4: Wire flash + announcement into page templates

Every page handler that renders a template needs to pass flash and announcement data. Rather than adding fields to every template struct, use a shared `PageContext` struct that each template includes.

**Files:**
- Create: `crates/web-api/src/page.rs`
- Modify: `crates/web-api/src/main.rs` (add `mod page;`)

**Step 1: Create PageContext**

```rust
use crate::flash::{self, FlashMessage};

pub struct PageContext {
    pub flash: Option<FlashMessage>,
    pub announcement: Option<String>,
}

impl PageContext {
    pub fn from_request(
        headers: &axum::http::HeaderMap,
        announcement: &Option<String>,
    ) -> Self {
        Self {
            flash: flash::read_flash(headers),
            announcement: announcement.clone(),
        }
    }
}
```

**Step 2: Register module in main.rs**

Add `mod page;` to module declarations.

**Step 3: Commit**

```
feat(web-api): add PageContext for flash and announcement data
```

---

### Task 5: Update dashboard template and handler

**Files:**
- Modify: `crates/web-api/templates/dashboard.html`
- Modify: `crates/web-api/src/routes/dashboard.rs`

**Step 1: Add PageContext to DashboardTemplate**

Read the dashboard handler to understand its current shape, then add:

```rust
use crate::flash;
use crate::page::PageContext;
```

Add `pub page: PageContext` to the template struct. Add `headers: HeaderMap` extractor to the handler. Construct `PageContext::from_request(&headers, &state.announcement)` and pass it.

Add `clear_flash()` header to the response when `page.flash.is_some()`.

**Step 2: Update dashboard.html**

Replace `{% block content %}` section opening with:

```html
{% block banner %}
{% if let Some(ref text) = page.announcement %}
<div class="announcement">{{ text }}</div>
{% endif %}
{% endblock %}
{% block flash %}
{% if let Some(ref msg) = page.flash %}
<div class="flash {% if msg.level == crate::flash::FlashLevel::Success %}flash-success{% else %}flash-error{% endif %}">{{ msg.text }}</div>
{% endif %}
{% endblock %}
{% block content %}
```

Note: askama may need the enum comparison done differently. If direct enum comparison doesn't work in the template, add a `pub is_error: bool` helper field to `FlashMessage` or a method, or pass `flash_class` as a string from the handler.

**Step 3: Commit**

```
feat(web-api): wire flash and announcement into dashboard
```

---

### Task 6: Update bouncers template and handler

**Files:**
- Modify: `crates/web-api/templates/bouncers.html`
- Modify: `crates/web-api/src/routes/bouncer.rs`

**Step 1: Add PageContext to BouncersTemplate**

Same pattern as dashboard: add `page: PageContext` field, `HeaderMap` extractor, construct context. Clear flash cookie when present.

**Step 2: Update bouncer create handler**

Change the error return from `Redirect::to("/bouncers")` to:

```rust
use crate::flash::set_flash;
use axum::response::AppendHeaders;

// on error:
let headers = AppendHeaders([set_flash("bouncer_create_failed")]);
return Err((headers, Redirect::to("/bouncers")));

// on success:
let headers = AppendHeaders([set_flash("bouncer_created")]);
Ok((headers, Redirect::to("/bouncers")))
```

Update the return type accordingly to `Result<(AppendHeaders<...>, Redirect), (AppendHeaders<...>, Redirect)>`.

**Step 3: Update bouncers.html**

Add flash/announcement blocks same pattern as dashboard.

**Step 4: Commit**

```
feat(web-api): wire flash into bouncer create/list
```

---

### Task 7: Update profile template and handler

**Files:**
- Modify: `crates/web-api/templates/profile.html`
- Modify: `crates/web-api/src/routes/profile.rs`

**Step 1: Migrate profile from query params to flash cookies**

Remove `ProfileQuery`, `saved: bool`, and `error: Option<String>` from `ProfileTemplate`. Add `page: PageContext` instead.

In `profile::update`:
- Replace `Redirect::to("/profile?saved=1")` with flash cookie `profile_saved`
- Replace `Redirect::to(&format!("/profile?error={msg}"))` with flash cookies `profile_save_failed` / `email_update_failed`

In `profile::index`:
- Remove `Query(params): Query<ProfileQuery>` extractor
- Add `headers: HeaderMap` extractor
- Construct `PageContext` and pass to template
- Clear flash cookie on response

**Step 2: Update profile.html**

Remove the inline `{% if saved %}` and `{% if let Some(msg) = error %}` blocks.
Add the standard flash/announcement blocks (same pattern as dashboard/bouncers).

**Step 3: Commit**

```
refactor(web-api): migrate profile flash from query params to cookies
```

---

### Task 8: Update remaining templates (billing, migrate)

**Files:**
- Modify: `crates/web-api/templates/billing.html`
- Modify: `crates/web-api/src/routes/billing.rs`
- Modify: `crates/web-api/templates/migrate.html`
- Modify: `crates/web-api/templates/migrate_result.html`
- Modify: `crates/web-api/src/routes/migrate.rs`

**Step 1: Add PageContext to all remaining templates**

Same pattern: add `page: PageContext` field, `HeaderMap` extractor, construct context, clear cookie. Add flash/announcement blocks to each template.

These pages may not have flash-triggering actions yet, but they still need the announcement banner and the flash block (in case a flash is set from a redirect that lands here).

**Step 2: Commit**

```
feat(web-api): wire flash and announcement into billing and migrate pages
```

---

### Task 9: Fix auth login failure redirect loop

**Files:**
- Modify: `crates/web-api/src/routes/auth.rs`

**Step 1: Add flash to auth error redirects**

The auth callback currently redirects to `/auth/login` on failure, which creates a redirect loop. Change error redirects to go to `/dashboard` (or `/` if unauthenticated) with a flash:

```rust
use crate::flash::set_flash;
use axum::response::AppendHeaders;

// For errors during OIDC callback, redirect to the landing page with a flash:
Err((AppendHeaders([set_flash("login_failed")]), Redirect::temporary("/")))
```

But wait -- `/` is the landing page (web-landing), not web-api. The flash cookie is on `my.irc.now` domain.

Better approach: add a simple `/auth/error` page that reads the flash and displays it. This avoids the redirect loop and cross-domain issues.

Add a route and minimal template:

```rust
pub async fn error_page(headers: HeaderMap, State(state): State<AppState>) -> impl IntoResponse {
    let flash = flash::read_flash(&headers);
    let clear = if flash.is_some() {
        Some(AppendHeaders([flash::clear_flash()]))
    } else {
        None
    };
    // render a simple error page with the flash message and a "try again" link
    (clear, AuthErrorTemplate { page: PageContext { flash, announcement: state.announcement.clone() } })
}
```

Route: `.route("/auth/error", get(routes::auth::error_page))`

Template `auth_error.html`:
```html
{% extends "base.html" %}
{% block title %}login error - irc.now{% endblock %}
{% block banner %}
{% if let Some(ref text) = page.announcement %}
<div class="announcement">{{ text }}</div>
{% endif %}
{% endblock %}
{% block flash %}
{% if let Some(ref msg) = page.flash %}
<div class="flash flash-error">{{ msg.text }}</div>
{% endif %}
{% endblock %}
{% block content %}
<div class="content">
  <h1>login error</h1>
  <div class="card">
    <p>something went wrong during login.</p>
    <a href="/auth/login" class="btn-fill">try again</a>
  </div>
</div>
{% endblock %}
```

Then change all `Redirect::temporary("/auth/login")` error returns in callback to:
```rust
(AppendHeaders([set_flash("login_failed")]), Redirect::temporary("/auth/error"))
```

**Step 2: Commit**

```
fix(web-api): replace auth redirect loop with error page and flash
```

---

### Task 10: DRY up the template flash/announcement blocks

**Files:**
- Create: `crates/web-api/templates/partials/flash.html`
- Create: `crates/web-api/templates/partials/announcement.html`
- Modify: all page templates

**Step 1: Create partials**

`partials/announcement.html`:
```html
{% if let Some(ref text) = page.announcement %}
<div class="announcement">{{ text }}</div>
{% endif %}
```

`partials/flash.html`:
```html
{% if let Some(ref msg) = page.flash %}
<div class="flash {{ msg.css_class() }}">{{ msg.text }}</div>
{% endif %}
```

Add a `css_class()` method to `FlashMessage`:
```rust
impl FlashMessage {
    pub fn css_class(&self) -> &'static str {
        match self.level {
            FlashLevel::Success => "flash-success",
            FlashLevel::Error => "flash-error",
        }
    }
}
```

**Step 2: Replace inline blocks in all templates**

Each template's banner/flash blocks become:
```html
{% block banner %}{% include "partials/announcement.html" %}{% endblock %}
{% block flash %}{% include "partials/flash.html" %}{% endblock %}
```

**Step 3: Commit**

```
refactor(web-api): extract flash and announcement template partials
```

---

### Task 11: Build and verify

**Step 1: Run cargo check**

```bash
cd crates/web-api && cargo check
```

Fix any compilation errors.

**Step 2: Run tests**

```bash
cargo test -p irc-now-web-api
```

**Step 3: Commit any fixes**

```
fix(web-api): address compilation issues from flash integration
```
