diff --git a/* b/*
deleted file mode 100644
index e69de29b..00000000
diff --git a/api/src/core/security.py b/api/src/core/security.py
index 9e6947da..68f55d06 100644
--- a/api/src/core/security.py
+++ b/api/src/core/security.py
@@ -20,7 +20,7 @@
class Resource(StrEnum):
ADMIN = "admin"
ORG_MANAGER = "org_manager"
- CONTENT_MANAGER = "content-manager"
+ CONTENT_MANAGER = "content_manager"
class Scope(StrEnum):
@@ -94,8 +94,8 @@ def _get_required_roles(self) -> set[str]:
(Resource.ADMIN.value, Scope.MANAGE.value): {"admin"},
(Resource.ORG_MANAGER.value, Scope.VIEW.value): {"org_manager"},
(Resource.ORG_MANAGER.value, Scope.MANAGE.value): {"org_manager"},
- (Resource.CONTENT_MANAGER.value, Scope.VIEW.value): {"content-manager"},
- (Resource.CONTENT_MANAGER.value, Scope.MANAGE.value): {"content-manager"},
+ (Resource.CONTENT_MANAGER.value, Scope.VIEW.value): {"content_manager"},
+ (Resource.CONTENT_MANAGER.value, Scope.MANAGE.value): {"content_manager"},
}
required_roles: set[str] = set()
@@ -132,6 +132,7 @@ async def __call__(
if required_roles.isdisjoint(token_roles):
permission_label = f"{' or '.join(self.resources)}#{self.scope}"
+ logging.error(f"403 ERROR: Token Roles: {token_roles}, Required: {required_roles}, Payload realm_access: {payload.get('realm_access')} resource_access: {payload.get('resource_access')}")
raise HTTPException(
status_code=403,
detail=f"Permission denied for '{permission_label}'",
diff --git a/api/src/routers/templates.py b/api/src/routers/templates.py
index 027c097b..51e91053 100644
--- a/api/src/routers/templates.py
+++ b/api/src/routers/templates.py
@@ -3,31 +3,46 @@
from fastapi import APIRouter, Depends, status
from src.core.security import Roles, Resource, Scope
+from src.core.dependencies import CurrentRealm, OAuth2Scheme
+from src.services.compliance.token_helpers import decode_token_verified
from src.services import templates as template_service
from src.services.templates import TemplateCreate, TemplateUpdate, TemplateOut
router = APIRouter()
+def get_org_context(token: str, realm: str) -> tuple[str, bool]:
+ claims = decode_token_verified(token)
+ roles = claims.get("realm_access", {}).get("roles", [])
+ is_content_manager = "content_manager" in roles
+ org_id = "platform" if is_content_manager else realm
+ return org_id, is_content_manager
+
+
@router.get("/templates", dependencies=[Depends(Roles([Resource.CONTENT_MANAGER, Resource.ORG_MANAGER], Scope.VIEW))])
-async def list_templates() -> List[TemplateOut]:
- return await template_service.list_templates()
+async def list_templates(token: OAuth2Scheme, realm: CurrentRealm) -> List[TemplateOut]:
+ org_id, is_content_manager = get_org_context(token, realm)
+ return await template_service.list_templates(org_id=org_id, include_platform=not is_content_manager)
@router.get("/templates/{template_id}", dependencies=[Depends(Roles([Resource.CONTENT_MANAGER, Resource.ORG_MANAGER], Scope.VIEW))])
-async def get_template(template_id: str) -> TemplateOut:
- return await template_service.get_template(template_id)
+async def get_template(template_id: str, token: OAuth2Scheme, realm: CurrentRealm) -> TemplateOut:
+ org_id, is_content_manager = get_org_context(token, realm)
+ return await template_service.get_template(template_id, org_id=org_id, can_view_platform=not is_content_manager)
-@router.post("/templates", status_code=status.HTTP_201_CREATED, dependencies=[Depends(Roles(Resource.CONTENT_MANAGER, Scope.MANAGE))])
-async def create_template(template: TemplateCreate) -> TemplateOut:
- return await template_service.create_template(template)
+@router.post("/templates", status_code=status.HTTP_201_CREATED, dependencies=[Depends(Roles([Resource.CONTENT_MANAGER, Resource.ORG_MANAGER], Scope.MANAGE))])
+async def create_template(template: TemplateCreate, token: OAuth2Scheme, realm: CurrentRealm) -> TemplateOut:
+ org_id, _ = get_org_context(token, realm)
+ return await template_service.create_template(template, org_id=org_id)
-@router.put("/templates/{template_id}", dependencies=[Depends(Roles(Resource.CONTENT_MANAGER, Scope.MANAGE))])
-async def update_template(template_id: str, template: TemplateUpdate) -> TemplateOut:
- return await template_service.update_template(template_id, template)
+@router.put("/templates/{template_id}", dependencies=[Depends(Roles([Resource.CONTENT_MANAGER, Resource.ORG_MANAGER], Scope.MANAGE))])
+async def update_template(template_id: str, template: TemplateUpdate, token: OAuth2Scheme, realm: CurrentRealm) -> TemplateOut:
+ org_id, _ = get_org_context(token, realm)
+ return await template_service.update_template(template_id, template, org_id=org_id)
-@router.delete("/templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(Roles(Resource.CONTENT_MANAGER, Scope.MANAGE))])
-async def delete_template(template_id: str) -> None:
- await template_service.delete_template(template_id)
+@router.delete("/templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(Roles([Resource.CONTENT_MANAGER, Resource.ORG_MANAGER], Scope.MANAGE))])
+async def delete_template(template_id: str, token: OAuth2Scheme, realm: CurrentRealm) -> None:
+ org_id, _ = get_org_context(token, realm)
+ await template_service.delete_template(template_id, org_id=org_id)
diff --git a/api/src/services/modules.py b/api/src/services/modules.py
index 6e43360d..1664b81a 100644
--- a/api/src/services/modules.py
+++ b/api/src/services/modules.py
@@ -15,6 +15,7 @@
from bson import ObjectId
from bson.errors import InvalidId
+from pymongo import ReturnDocument
from fastapi import HTTPException, status
from src.core.mongo import get_modules_collection
@@ -139,19 +140,23 @@ async def update_module(module_id: str, payload: ModuleUpdate, realm: str) -> Mo
oid = _to_object_id(module_id)
col = get_modules_collection()
- existing = await col.find_one({"_id": oid, "realm": realm})
- if existing is None:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=MODULE_NOT_FOUND)
-
data = payload.model_dump()
- data.update(
- updated_at=_now(),
- version=existing.get("version", 1) + 1,
+ # We must explicitly look up existing version or default to 1 for atomic bump?
+ # Actually wait, we can do $inc for version! Let's just do it directly.
+ data["updated_at"] = _now()
+
+ updated = await col.find_one_and_update(
+ {"_id": oid, "realm": realm},
+ {
+ "$set": data,
+ "$inc": {"version": 1}
+ },
+ return_document=ReturnDocument.AFTER
)
+ if updated is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=MODULE_NOT_FOUND)
- await col.update_one({"_id": oid}, {"$set": data})
- updated = await col.find_one({"_id": oid})
- return _doc_to_out(updated) # type: ignore[arg-type]
+ return _doc_to_out(updated)
async def patch_module(module_id: str, payload: ModulePatch, realm: str) -> ModuleOut:
@@ -159,22 +164,27 @@ async def patch_module(module_id: str, payload: ModulePatch, realm: str) -> Modu
oid = _to_object_id(module_id)
col = get_modules_collection()
- existing = await col.find_one({"_id": oid, "realm": realm})
- if existing is None:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=MODULE_NOT_FOUND)
-
- # Only include fields that were explicitly provided (exclude_unset)
delta = payload.model_dump(exclude_unset=True)
if not delta:
- # Nothing to change — return as-is to avoid a spurious DB round-trip
+ existing = await col.find_one({"_id": oid, "realm": realm})
+ if existing is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=MODULE_NOT_FOUND)
return _doc_to_out(existing)
delta["updated_at"] = _now()
- delta["version"] = existing.get("version", 1) + 1
- await col.update_one({"_id": oid}, {"$set": delta})
- updated = await col.find_one({"_id": oid})
- return _doc_to_out(updated) # type: ignore[arg-type]
+ updated = await col.find_one_and_update(
+ {"_id": oid, "realm": realm},
+ {
+ "$set": delta,
+ "$inc": {"version": 1}
+ },
+ return_document=ReturnDocument.AFTER
+ )
+ if updated is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=MODULE_NOT_FOUND)
+
+ return _doc_to_out(updated)
async def delete_module(module_id: str, realm: str) -> None:
@@ -182,8 +192,6 @@ async def delete_module(module_id: str, realm: str) -> None:
oid = _to_object_id(module_id)
col = get_modules_collection()
- existing = await col.find_one({"_id": oid, "realm": realm})
- if existing is None:
+ deleted = await col.find_one_and_delete({"_id": oid, "realm": realm})
+ if deleted is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=MODULE_NOT_FOUND)
-
- await col.delete_one({"_id": oid})
diff --git a/api/src/services/platform_admin/realm_handler.py b/api/src/services/platform_admin/realm_handler.py
index c14a1912..618a259b 100644
--- a/api/src/services/platform_admin/realm_handler.py
+++ b/api/src/services/platform_admin/realm_handler.py
@@ -46,6 +46,22 @@ def create_realm_in_keycloak(self, realm: RealmCreate, session: Session) -> Real
features=realm.features,
)
+ try:
+ token = self.admin._get_admin_token()
+ users = self.admin.list_users(realm.name)
+ org_manager_user = next((u for u in users if str(u.get("username")).lower() == "org_manager"), None)
+ if org_manager_user:
+ client = self.admin.keycloak_client.get_client_by_client_id(realm.name, token, "realm-management")
+ if client:
+ client_id = client.get("id")
+ client_role = self.admin.keycloak_client.get_client_role(realm.name, token, client_id, "realm-admin")
+ if client_role:
+ self.admin.keycloak_client.assign_client_roles(
+ realm.name, token, org_manager_user.get("id"), client_id, [client_role]
+ )
+ except Exception as e:
+ print(f"Warning: Failed to bootstrap ORG_MANAGER with realm-admin: {e}")
+
self._ensure_realm(session, realm.name, normalized_domain)
diff --git a/api/src/services/sending_profile.py b/api/src/services/sending_profile.py
index 1f846432..5fbb73fa 100644
--- a/api/src/services/sending_profile.py
+++ b/api/src/services/sending_profile.py
@@ -57,6 +57,8 @@ def test_sending_profile_configuration(self, profile: SendingProfileCreate) -> s
raise ValueError("Server disconnected unexpectedly")
except TimeoutError:
raise ValueError("Connection timed out")
+ except OSError as e:
+ raise ValueError(f"Network error: {e}")
except Exception as e:
raise RuntimeError(str(e))
diff --git a/api/src/services/templates.py b/api/src/services/templates.py
index 83f88ca3..45560d79 100644
--- a/api/src/services/templates.py
+++ b/api/src/services/templates.py
@@ -3,7 +3,9 @@
from typing import Optional, List, Literal, Dict, Any
from fastapi import HTTPException, status
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, model_validator
+
+from pymongo import ReturnDocument
from src.core.mongo import get_templates_collection, serialize_document, to_object_id
@@ -31,14 +33,29 @@ def replace(match: re.Match) -> str:
return pattern.sub(replace, html)
-class TemplateCreate(BaseModel):
+class TemplateBase(BaseModel):
name: str = Field(..., description="Template display name")
path: PathLiteral = Field(..., description="Content path to organize templates")
- subject: str = Field(..., description="Email subject line")
+ subject: Optional[str] = Field(None, description="Email subject line")
category: Optional[str] = Field(None, description="Grouping, e.g. 'finance'")
description: Optional[str] = Field(None, description="Short description for UI")
html: str = Field(..., description="HTML content of the phishing template")
+class TemplateCreate(TemplateBase):
+
+ @model_validator(mode='after')
+ def validate_template_requirements(self) -> 'TemplateCreate':
+ if self.path == "/templates/emails/":
+ if not self.subject or not self.subject.strip():
+ raise ValueError("Email templates must have a subject line.")
+ if "${{redirect}}" not in self.html or "${{pixel}}" not in self.html:
+ raise ValueError("Email templates must include both ${{redirect}} and ${{pixel}}.")
+ elif self.path == "/templates/pages/":
+ if "${{redirect}}" not in self.html:
+ raise ValueError("Landing page templates must include the ${{redirect}} variable.")
+ self.subject = None
+ return self
+
class TemplateUpdate(BaseModel):
name: Optional[str] = None
@@ -48,23 +65,51 @@ class TemplateUpdate(BaseModel):
description: Optional[str] = None
html: Optional[str] = None
-
-class TemplateOut(TemplateCreate):
+ @model_validator(mode='after')
+ def validate_template_requirements(self) -> 'TemplateUpdate':
+ if self.path == "/templates/emails/":
+ if self.subject is not None and not self.subject.strip():
+ raise ValueError("Email templates must have a valid subject line.")
+ if self.html is not None:
+ if "${{redirect}}" not in self.html or "${{pixel}}" not in self.html:
+ raise ValueError("Email templates must include both ${{redirect}} and ${{pixel}}.")
+ elif self.path == "/templates/pages/":
+ if self.html is not None and "${{redirect}}" not in self.html:
+ raise ValueError("Landing page templates must include the ${{redirect}} variable.")
+ self.subject = None
+ return self
+
+
+class TemplateOut(TemplateBase):
id: str
+ org_id: str = "platform"
created_at: datetime
updated_at: datetime
-async def list_templates() -> List[TemplateOut]:
+async def list_templates(org_id: str, include_platform: bool = False) -> List[TemplateOut]:
collection = get_templates_collection()
- cursor = collection.find().sort("updated_at", -1)
+
+ platform_conds = [{"org_id": "platform"}, {"org_id": {"$exists": False}}, {"org_id": None}]
+
+ if org_id == "platform":
+ query = {"$or": platform_conds}
+ else:
+ query = {"org_id": org_id}
+ if include_platform:
+ query = {"$or": [{"org_id": org_id}] + platform_conds}
+
+ cursor = collection.find(query).sort("updated_at", -1)
results: List[TemplateOut] = []
async for doc in cursor:
- results.append(TemplateOut.model_validate(serialize_document(doc)))
+ doc_dict = serialize_document(doc)
+ if "org_id" not in doc_dict or not doc_dict["org_id"]:
+ doc_dict["org_id"] = "platform"
+ results.append(TemplateOut.model_validate(doc_dict))
return results
-async def get_template(template_id: str) -> TemplateOut:
+async def get_template(template_id: str, org_id: str, can_view_platform: bool = False) -> TemplateOut:
collection = get_templates_collection()
try:
oid = to_object_id(template_id)
@@ -74,13 +119,23 @@ async def get_template(template_id: str) -> TemplateOut:
doc = await collection.find_one({"_id": oid})
if not doc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
- return TemplateOut.model_validate(serialize_document(doc))
+
+ doc_dict = serialize_document(doc)
+ doc_org_id = doc_dict.get("org_id") or "platform"
+ doc_dict["org_id"] = doc_org_id
+
+ if doc_org_id != org_id:
+ if not (can_view_platform and doc_org_id == "platform"):
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized to view this template")
+ return TemplateOut.model_validate(doc_dict)
-async def create_template(template: TemplateCreate) -> TemplateOut:
+
+async def create_template(template: TemplateCreate, org_id: str) -> TemplateOut:
collection = get_templates_collection()
now = datetime.utcnow()
payload = template.model_dump()
+ payload["org_id"] = org_id
payload["created_at"] = now
payload["updated_at"] = now
@@ -88,39 +143,65 @@ async def create_template(template: TemplateCreate) -> TemplateOut:
doc = await collection.find_one({"_id": result.inserted_id})
if not doc:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create template")
- return TemplateOut.model_validate(serialize_document(doc))
+
+ doc_dict = serialize_document(doc)
+ doc_dict["org_id"] = org_id
+ return TemplateOut.model_validate(doc_dict)
-async def update_template(template_id: str, template: TemplateUpdate) -> TemplateOut:
+async def update_template(template_id: str, template: TemplateUpdate, org_id: str) -> TemplateOut:
collection = get_templates_collection()
try:
oid = to_object_id(template_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid template id")
-
+
payload = {k: v for k, v in template.model_dump().items() if v is not None}
if not payload:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No fields to update")
payload["updated_at"] = datetime.utcnow()
- result = await collection.update_one({"_id": oid}, {"$set": payload})
- if result.matched_count == 0:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
-
- doc = await collection.find_one({"_id": oid})
+ filter_q: Dict[str, Any] = {"_id": oid}
+ if org_id == "platform":
+ filter_q["$or"] = [{"org_id": "platform"}, {"org_id": {"$exists": False}}, {"org_id": None}]
+ else:
+ filter_q["org_id"] = org_id
+
+ doc = await collection.find_one_and_update(
+ filter_q,
+ {"$set": payload},
+ return_document=ReturnDocument.AFTER
+ )
+
if not doc:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
- return TemplateOut.model_validate(serialize_document(doc))
+ # Fallback to check if it's 404 or 403
+ existing = await collection.find_one({"_id": oid})
+ if not existing:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized to edit this template")
+
+ doc_dict = serialize_document(doc)
+ doc_dict["org_id"] = doc_dict.get("org_id") or "platform"
+ return TemplateOut.model_validate(doc_dict)
-async def delete_template(template_id: str) -> None:
+async def delete_template(template_id: str, org_id: str) -> None:
collection = get_templates_collection()
try:
oid = to_object_id(template_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid template id")
- result = await collection.delete_one({"_id": oid})
- if result.deleted_count == 0:
- raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
+ filter_q: Dict[str, Any] = {"_id": oid}
+ if org_id == "platform":
+ filter_q["$or"] = [{"org_id": "platform"}, {"org_id": {"$exists": False}}, {"org_id": None}]
+ else:
+ filter_q["org_id"] = org_id
+
+ deleted = await collection.find_one_and_delete(filter_q)
+ if not deleted:
+ existing = await collection.find_one({"_id": oid})
+ if not existing:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized to delete this template")
diff --git a/deployment/docker-compose.dev.yml b/deployment/docker-compose.dev.yml
index 3f142125..ea8b6010 100644
--- a/deployment/docker-compose.dev.yml
+++ b/deployment/docker-compose.dev.yml
@@ -10,7 +10,7 @@ services:
ports:
- "5432:5432"
volumes:
- - db_data:/var/lib/postgresql
+ - db_data:/var/lib/postgresql/data
- ../db/init-dbs.sql:/docker-entrypoint-initdb.d/init-dbs.sql
healthcheck:
@@ -136,6 +136,7 @@ services:
CLIENT_SECRET: ${CLIENT_SECRET}
WEB_URL: ${WEB_URL}
API_URL: ${API_URL}
+ JAVA_OPTS_APPEND: "-Dkeycloak.migration.strategy=IGNORE_EXISTING"
healthcheck:
test:
@@ -159,6 +160,7 @@ services:
- ../keycloak/themes:/opt/keycloak/themes:ro
- ../keycloak/imports:/opt/keycloak/data/import:ro
- ../keycloak/providers:/opt/keycloak/providers:ro
+ - keycloak_data:/opt/keycloak/data
api:
build:
@@ -240,3 +242,4 @@ volumes:
rabbitmq_data:
mongo_data:
garage_data:
+ keycloak_data:
diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml
index 195484be..e65a823e 100644
--- a/deployment/docker-compose.yml
+++ b/deployment/docker-compose.yml
@@ -36,7 +36,7 @@ services:
expose:
- "5432"
volumes:
- - db_data:/var/lib/postgresql
+ - db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
@@ -160,6 +160,7 @@ services:
KC_DB_URL: jdbc:postgresql://db:5432/keycloak
KC_DB_USERNAME: ${POSTGRES_USER}
KC_DB_PASSWORD: ${POSTGRES_PASSWORD}
+ CLIENT_SECRET: ${CLIENT_SECRET}
KC_HTTP_ENABLED: "true"
KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "false"
@@ -168,9 +169,9 @@ services:
KC_HOSTNAME_STRICT: "false"
KC_HEALTH_ENABLED: "true"
- CLIENT_SECRET: ${CLIENT_SECRET}
WEB_URL: ${WEB_URL}
API_URL: ${API_URL}
+ JAVA_OPTS_APPEND: "-Dkeycloak.migration.strategy=IGNORE_EXISTING"
healthcheck:
test: ["CMD", "true"]
interval: 30s
@@ -183,6 +184,9 @@ services:
db:
condition: service_healthy
+ volumes:
+ - keycloak_data:/opt/keycloak/data
+
api:
build:
context: ../api
@@ -270,3 +274,4 @@ volumes:
garage_data:
rabbitmq_data:
mongo_data:
+ keycloak_data:
diff --git a/keycloak/imports/realm-export.json b/keycloak/imports/realm-export.json
index 603ac179..0e83a54b 100644
--- a/keycloak/imports/realm-export.json
+++ b/keycloak/imports/realm-export.json
@@ -80,6 +80,7 @@
"enabled": true,
"publicClient": true,
"redirectUris": [
+ "${WEB_URL}/*",
"${WEB_URL}/admin/*",
"${WEB_URL}/content-manager/*"
],
diff --git a/web/src/Pages/templates.tsx b/web/src/Pages/templates.tsx
index 9bef8d8d..6b1a75c0 100644
--- a/web/src/Pages/templates.tsx
+++ b/web/src/Pages/templates.tsx
@@ -1,13 +1,15 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { Plus, Eye, Layout } from "lucide-react";
-import { apiClient } from "@/lib/api-client";
+import { Plus } from "lucide-react";
+import { toast } from "sonner";
+import { templateApi } from "@/services/templateApi";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import DisplayModeToggle from "@/components/shared/DisplayModeToggle";
import RefreshButton from "@/components/shared/RefreshButton";
import SearchBar from "@/components/shared/SearchBar";
+import { TemplateCard } from "@/components/templates/TemplateCard";
import { TemplateFormModal } from "@/components/templates/TemplateFormModal";
import { TemplatePreviewModal } from "@/components/templates/TemplatePreviewModal";
import { LoadingGrid } from "@/components/templates/LoadingGrid";
@@ -22,7 +24,6 @@ type ViewMode = "grid" | "table";
const templateMatchesQuery = (template: Template, query: string) => {
const haystack = [
template.name,
- template.subject,
template.description,
template.category
]
@@ -38,13 +39,14 @@ export default function TemplatesPage() {
Template[]
>({
queryKey: ["templates"],
- queryFn: () => apiClient.get("/templates"),
+ queryFn: () => templateApi.getTemplates(),
staleTime: 30_000
});
const qc = useQueryClient();
const [previewId, setPreviewId] = useState
- {template.name}
-
- {template.subject || template.description || "No description"}
-
- Updated {new Date(template.updated_at).toLocaleDateString()}
-
- {template.name}
-
- {template.subject}
-
- {template.description}
-
- Updated {new Date(template.updated_at).toLocaleDateString()}
-
- {template.name}
-
- {template.subject}
-
- {template.description}
-
- Updated {new Date(template.updated_at).toLocaleDateString()}
-
{template.subject}
- )} {template.description && ({template.description} @@ -165,11 +162,6 @@ function LandingPageTemplateGridCard({
{template.name}
- {template.subject && template.subject !== template.name && ( -- {template.subject} -
- )} {template.description && ({template.description} @@ -220,7 +212,7 @@ function TemplateListRow({ {template.name}
- {template.subject || template.description || "No description"} + {template.description || "No description"}
{template.description}
} -- {excerpt} - {excerpt.length === 140 && "…"} -
-+ {template.description || "No description provided."} +
+ + {/* Footer: Metadata & Actions */} +