diff --git a/frontend/app/config.py b/frontend/app/config.py
new file mode 100644
index 0000000..996387f
--- /dev/null
+++ b/frontend/app/config.py
@@ -0,0 +1,9 @@
+"""Flask frontend configuration."""
+import os
+
+
+class Config:
+ SECRET_KEY = os.getenv("FLASK_SECRET_KEY", "dev-secret-change-in-production")
+ BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000")
+ SESSION_COOKIE_HTTPONLY = True
+ SESSION_COOKIE_SAMESITE = "Lax"
diff --git a/frontend/app/main.py b/frontend/app/main.py
index e69de29..985644b 100644
--- a/frontend/app/main.py
+++ b/frontend/app/main.py
@@ -0,0 +1,136 @@
+"""Flask frontend application."""
+import functools
+
+import httpx
+from flask import (
+ Flask,
+ flash,
+ redirect,
+ render_template,
+ request,
+ session,
+ url_for,
+)
+
+from frontend.app.config import Config
+
+app = Flask(__name__)
+app.config.from_object(Config)
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _backend(path: str) -> str:
+ return f"{app.config['BACKEND_URL']}{path}"
+
+
+def _api(method: str, path: str, *, token: str | None = None, **kwargs):
+ headers = kwargs.pop("headers", {})
+ if token:
+ headers["Authorization"] = f"Bearer {token}"
+ return httpx.request(method, _backend(path), headers=headers, timeout=30, **kwargs)
+
+
+def login_required(view):
+ @functools.wraps(view)
+ def wrapped(*args, **kwargs):
+ if "access_token" not in session:
+ return redirect(url_for("login"))
+ return view(*args, **kwargs)
+ return wrapped
+
+
+# ---------------------------------------------------------------------------
+# Auth routes
+# ---------------------------------------------------------------------------
+
+@app.get("/")
+def index():
+ if "access_token" in session:
+ return redirect(url_for("dashboard"))
+ return redirect(url_for("login"))
+
+
+@app.route("/login", methods=["GET", "POST"])
+def login():
+ if request.method == "POST":
+ email = request.form["email"]
+ password = request.form["password"]
+ resp = _api("POST", "/auth/login", json={"email": email, "password": password})
+ if resp.status_code == 200:
+ data = resp.json()
+ session["access_token"] = data["access_token"]
+ session["refresh_token"] = data["refresh_token"]
+ return redirect(url_for("dashboard"))
+ flash("Invalid email or password.", "error")
+ return render_template("login.html")
+
+
+@app.route("/register", methods=["GET", "POST"])
+def register():
+ if request.method == "POST":
+ email = request.form["email"]
+ password = request.form["password"]
+ resp = _api("POST", "/auth/register", json={"email": email, "password": password})
+ if resp.status_code == 201:
+ flash("Account created. Please log in.", "success")
+ return redirect(url_for("login"))
+ detail = resp.json().get("detail", "Registration failed.")
+ flash(detail, "error")
+ return render_template("register.html")
+
+
+@app.get("/logout")
+def logout():
+ refresh_token = session.get("refresh_token")
+ if refresh_token:
+ _api("POST", "/auth/logout", json={"refresh_token": refresh_token})
+ session.clear()
+ return redirect(url_for("login"))
+
+
+# ---------------------------------------------------------------------------
+# Authenticated routes
+# ---------------------------------------------------------------------------
+
+@app.get("/dashboard")
+@login_required
+def dashboard():
+ token = session["access_token"]
+ resp = _api("GET", "/users/me", token=token)
+ user = resp.json() if resp.status_code == 200 else {}
+ return render_template("dashboard.html", user=user)
+
+
+@app.route("/generate", methods=["GET", "POST"])
+@login_required
+def generate():
+ result = None
+ error = None
+ if request.method == "POST":
+ gen_type = request.form.get("type", "text")
+ model = request.form.get("model", "").strip()
+ prompt = request.form.get("prompt", "").strip()
+ token = session["access_token"]
+
+ if gen_type == "text":
+ resp = _api("POST", "/generate/text", token=token,
+ json={"model": model, "prompt": prompt})
+ elif gen_type == "image":
+ resp = _api("POST", "/generate/image", token=token,
+ json={"model": model, "prompt": prompt})
+ elif gen_type == "video":
+ resp = _api("POST", "/generate/video", token=token,
+ json={"model": model, "prompt": prompt})
+ else:
+ resp = None
+
+ if resp is not None and resp.status_code == 200:
+ result = resp.json()
+ else:
+ detail = resp.json().get("detail", "Generation failed.") if resp is not None else "Unknown error."
+ error = detail
+
+ return render_template("generate.html", result=result, error=error)
diff --git a/frontend/app/static/style.css b/frontend/app/static/style.css
new file mode 100644
index 0000000..b4bd855
--- /dev/null
+++ b/frontend/app/static/style.css
@@ -0,0 +1,150 @@
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ font-family:
+ system-ui,
+ -apple-system,
+ sans-serif;
+ background: #f5f5f5;
+ color: #222;
+ min-height: 100vh;
+}
+
+/* Nav */
+header {
+ background: #1a1a2e;
+ padding: 0 1.5rem;
+}
+nav {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ height: 3.5rem;
+}
+.brand {
+ color: #e0e0ff;
+ font-weight: 700;
+ text-decoration: none;
+ font-size: 1.1rem;
+}
+.nav-links a {
+ color: #c0c0dd;
+ text-decoration: none;
+ margin-left: 1.25rem;
+ font-size: 0.9rem;
+}
+.nav-links a:hover {
+ color: #fff;
+}
+
+/* Main */
+main {
+ max-width: 640px;
+ margin: 2rem auto;
+ padding: 0 1rem;
+}
+
+/* Alerts */
+.alert {
+ padding: 0.75rem 1rem;
+ border-radius: 6px;
+ margin-bottom: 1rem;
+ font-size: 0.9rem;
+}
+.alert-success {
+ background: #d4edda;
+ color: #155724;
+}
+.alert-error {
+ background: #f8d7da;
+ color: #721c24;
+}
+
+/* Card */
+.card {
+ background: #fff;
+ border-radius: 10px;
+ padding: 2rem;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
+}
+.card h1 {
+ font-size: 1.5rem;
+ margin-bottom: 1.5rem;
+}
+
+/* Forms */
+form label {
+ display: block;
+ font-size: 0.85rem;
+ font-weight: 600;
+ margin-bottom: 0.3rem;
+ margin-top: 1rem;
+}
+form input,
+form select,
+form textarea {
+ width: 100%;
+ padding: 0.55rem 0.75rem;
+ border: 1px solid #ccc;
+ border-radius: 6px;
+ font-size: 0.95rem;
+ font-family: inherit;
+}
+form input:focus,
+form select:focus,
+form textarea:focus {
+ outline: none;
+ border-color: #5c6bc0;
+ box-shadow: 0 0 0 2px rgba(92, 107, 192, 0.2);
+}
+button[type="submit"],
+.btn {
+ display: inline-block;
+ margin-top: 1.25rem;
+ padding: 0.6rem 1.4rem;
+ background: #5c6bc0;
+ color: #fff;
+ border: none;
+ border-radius: 6px;
+ font-size: 0.95rem;
+ cursor: pointer;
+ text-decoration: none;
+}
+button[type="submit"]:hover,
+.btn:hover {
+ background: #3f51b5;
+}
+
+/* Result */
+.result {
+ margin-top: 1.5rem;
+ padding-top: 1.5rem;
+ border-top: 1px solid #eee;
+}
+.result h2 {
+ margin-bottom: 0.75rem;
+ font-size: 1.1rem;
+}
+pre {
+ background: #f0f0f0;
+ padding: 1rem;
+ border-radius: 6px;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+.generated-image {
+ max-width: 100%;
+ border-radius: 8px;
+ margin-top: 0.5rem;
+}
+.generated-video {
+ max-width: 100%;
+ border-radius: 8px;
+ margin-top: 0.5rem;
+}
diff --git a/frontend/app/templates/base.html b/frontend/app/templates/base.html
new file mode 100644
index 0000000..378d8ec
--- /dev/null
+++ b/frontend/app/templates/base.html
@@ -0,0 +1,36 @@
+
+
+
+
+
+ {% block title %}AI Allucanget{% endblock %}
+
+
+
+
+
+
+ {% with messages = get_flashed_messages(with_categories=true) %}
+ {% for category, message in messages %}
+ {{ message }}
+ {% endfor %}
+ {% endwith %}
+
+ {% block content %}{% endblock %}
+
+
+
diff --git a/frontend/app/templates/dashboard.html b/frontend/app/templates/dashboard.html
new file mode 100644
index 0000000..6d52fda
--- /dev/null
+++ b/frontend/app/templates/dashboard.html
@@ -0,0 +1,9 @@
+{% extends "base.html" %}
+{% block title %}Dashboard — AI Allucanget{% endblock %}
+{% block content %}
+
+
Welcome{% if user.get('email') %}, {{ user.email }}{% endif %}
+
Role: {{ user.get('role', 'user') }}
+
Start generating
+
+{% endblock %}
diff --git a/frontend/app/templates/generate.html b/frontend/app/templates/generate.html
new file mode 100644
index 0000000..7597f49
--- /dev/null
+++ b/frontend/app/templates/generate.html
@@ -0,0 +1,47 @@
+{% extends "base.html" %}
+{% block title %}Generate — AI Allucanget{% endblock %}
+{% block content %}
+
+
Generate
+
+
+ {% if error %}
+
{{ error }}
+ {% endif %}
+
+ {% if result %}
+
+ {% if result.get('content') %}
+
Result
+
{{ result.content }}
+ {% elif result.get('images') %}
+
Generated image{{ 's' if result.images|length > 1 }}
+ {% for img in result.images %}
+

+ {% endfor %}
+ {% elif result.get('status') %}
+
Video job
+
Status: {{ result.status }}
+ {% if result.get('video_url') %}
+
+ {% endif %}
+ {% endif %}
+
+ {% endif %}
+
+{% endblock %}
diff --git a/frontend/app/templates/login.html b/frontend/app/templates/login.html
new file mode 100644
index 0000000..75ffa76
--- /dev/null
+++ b/frontend/app/templates/login.html
@@ -0,0 +1,17 @@
+{% extends "base.html" %}
+{% block title %}Log in — AI Allucanget{% endblock %}
+{% block content %}
+
+{% endblock %}
diff --git a/frontend/app/templates/register.html b/frontend/app/templates/register.html
new file mode 100644
index 0000000..543cbc9
--- /dev/null
+++ b/frontend/app/templates/register.html
@@ -0,0 +1,17 @@
+{% extends "base.html" %}
+{% block title %}Register — AI Allucanget{% endblock %}
+{% block content %}
+
+{% endblock %}
diff --git a/frontend/tests/test_frontend.py b/frontend/tests/test_frontend.py
new file mode 100644
index 0000000..2b5e76d
--- /dev/null
+++ b/frontend/tests/test_frontend.py
@@ -0,0 +1,189 @@
+"""Frontend integration tests — backend API calls are fully mocked."""
+import os
+import pytest
+from unittest.mock import MagicMock, patch
+
+os.environ.setdefault("FLASK_SECRET_KEY", "test-secret")
+os.environ.setdefault("BACKEND_URL", "http://backend-mock")
+
+from frontend.app.main import app # noqa: E402
+
+
+@pytest.fixture
+def client():
+ app.config["TESTING"] = True
+ app.config["WTF_CSRF_ENABLED"] = False
+ with app.test_client() as c:
+ yield c
+
+
+def _mock_response(status_code: int, json_data: dict) -> MagicMock:
+ m = MagicMock()
+ m.status_code = status_code
+ m.json.return_value = json_data
+ return m
+
+
+# ---------------------------------------------------------------------------
+# Index redirect
+# ---------------------------------------------------------------------------
+
+def test_index_redirects_to_login(client):
+ resp = client.get("/")
+ assert resp.status_code == 302
+ assert "/login" in resp.headers["Location"]
+
+
+def test_index_redirects_to_dashboard_when_logged_in(client):
+ with client.session_transaction() as sess:
+ sess["access_token"] = "tok"
+ sess["refresh_token"] = "ref"
+ resp = client.get("/")
+ assert resp.status_code == 302
+ assert "/dashboard" in resp.headers["Location"]
+
+
+# ---------------------------------------------------------------------------
+# Login
+# ---------------------------------------------------------------------------
+
+def test_login_page_renders(client):
+ resp = client.get("/login")
+ assert resp.status_code == 200
+ assert b"Log in" in resp.data
+
+
+def test_login_success(client):
+ mock = _mock_response(200, {"access_token": "acc", "refresh_token": "ref"})
+ with patch("frontend.app.main.httpx.request", return_value=mock):
+ resp = client.post("/login", data={"email": "u@example.com", "password": "secret"})
+ assert resp.status_code == 302
+ assert "/dashboard" in resp.headers["Location"]
+
+
+def test_login_failure_shows_error(client):
+ mock = _mock_response(401, {"detail": "Invalid credentials."})
+ with patch("frontend.app.main.httpx.request", return_value=mock):
+ resp = client.post("/login", data={"email": "u@example.com", "password": "wrong"})
+ assert resp.status_code == 200
+ assert b"Invalid email or password" in resp.data
+
+
+# ---------------------------------------------------------------------------
+# Register
+# ---------------------------------------------------------------------------
+
+def test_register_page_renders(client):
+ resp = client.get("/register")
+ assert resp.status_code == 200
+ assert b"Create account" in resp.data
+
+
+def test_register_success_redirects_to_login(client):
+ mock = _mock_response(201, {"id": "abc", "email": "u@example.com", "role": "user"})
+ with patch("frontend.app.main.httpx.request", return_value=mock):
+ resp = client.post("/register", data={"email": "u@example.com", "password": "secret123"})
+ assert resp.status_code == 302
+ assert "/login" in resp.headers["Location"]
+
+
+def test_register_duplicate_shows_error(client):
+ mock = _mock_response(409, {"detail": "Email already registered."})
+ with patch("frontend.app.main.httpx.request", return_value=mock):
+ resp = client.post("/register", data={"email": "dup@example.com", "password": "secret123"})
+ assert resp.status_code == 200
+ assert b"Email already registered" in resp.data
+
+
+# ---------------------------------------------------------------------------
+# Logout
+# ---------------------------------------------------------------------------
+
+def test_logout_clears_session_and_redirects(client):
+ with client.session_transaction() as sess:
+ sess["access_token"] = "tok"
+ sess["refresh_token"] = "ref"
+ mock = _mock_response(204, {})
+ with patch("frontend.app.main.httpx.request", return_value=mock):
+ resp = client.get("/logout")
+ assert resp.status_code == 302
+ assert "/login" in resp.headers["Location"]
+ with client.session_transaction() as sess:
+ assert "access_token" not in sess
+
+
+# ---------------------------------------------------------------------------
+# Dashboard
+# ---------------------------------------------------------------------------
+
+def test_dashboard_requires_login(client):
+ resp = client.get("/dashboard")
+ assert resp.status_code == 302
+ assert "/login" in resp.headers["Location"]
+
+
+def test_dashboard_renders_user_info(client):
+ with client.session_transaction() as sess:
+ sess["access_token"] = "tok"
+ mock = _mock_response(200, {"id": "1", "email": "u@example.com", "role": "user"})
+ with patch("frontend.app.main.httpx.request", return_value=mock):
+ resp = client.get("/dashboard")
+ assert resp.status_code == 200
+ assert b"u@example.com" in resp.data
+
+
+# ---------------------------------------------------------------------------
+# Generate
+# ---------------------------------------------------------------------------
+
+def test_generate_page_requires_login(client):
+ resp = client.get("/generate")
+ assert resp.status_code == 302
+ assert "/login" in resp.headers["Location"]
+
+
+def test_generate_page_renders(client):
+ with client.session_transaction() as sess:
+ sess["access_token"] = "tok"
+ resp = client.get("/generate")
+ assert resp.status_code == 200
+ assert b"Generate" in resp.data
+
+
+def test_generate_text_success(client):
+ with client.session_transaction() as sess:
+ sess["access_token"] = "tok"
+ mock = _mock_response(200, {"id": "g1", "model": "openai/gpt-4o", "content": "Hello world", "usage": None})
+ with patch("frontend.app.main.httpx.request", return_value=mock):
+ resp = client.post("/generate", data={
+ "type": "text", "model": "openai/gpt-4o", "prompt": "Say hello"
+ })
+ assert resp.status_code == 200
+ assert b"Hello world" in resp.data
+
+
+def test_generate_image_success(client):
+ with client.session_transaction() as sess:
+ sess["access_token"] = "tok"
+ mock = _mock_response(200, {
+ "id": "g2", "model": "openai/dall-e-3",
+ "images": [{"url": "https://example.com/img.png", "revised_prompt": None, "b64_json": None}]
+ })
+ with patch("frontend.app.main.httpx.request", return_value=mock):
+ resp = client.post("/generate", data={
+ "type": "image", "model": "openai/dall-e-3", "prompt": "A cat"
+ })
+ assert resp.status_code == 200
+ assert b"example.com/img.png" in resp.data
+
+
+def test_generate_upstream_error_shows_message(client):
+ with client.session_transaction() as sess:
+ sess["access_token"] = "tok"
+ mock = _mock_response(502, {"detail": "OpenRouter error: timeout"})
+ with patch("frontend.app.main.httpx.request", return_value=mock):
+ resp = client.post("/generate", data={
+ "type": "text", "model": "openai/gpt-4o", "prompt": "Hi"
+ })
+ assert resp.status_code == 200
+ assert b"OpenRouter error" in resp.data