Skip to content

Commit 72a22b2

Browse files
Organization toggling!
1 parent 895dd9e commit 72a22b2

13 files changed

Lines changed: 672 additions & 75 deletions

File tree

.claude/settings.local.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
"Find",
1212
"Search",
1313
"WebSearch",
14-
"WebFetch(*)"
14+
"WebFetch(*)",
15+
"Skill(browser-automation)",
16+
"Skill(browser-automation:*)"
1517
]
1618
}
1719
}

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ dev = [
3434
"ty>=0.0.21",
3535
"ruff>=0.15.5",
3636
"pytest-jinja-check[fastapi]>=1.0.2",
37+
"pytest-playwright>=0.7.2",
3738
]
3839

3940
[tool.ty.rules]

routers/core/dashboard.py

Lines changed: 85 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
from typing import Optional
2-
from fastapi import APIRouter, Depends, Request
1+
from typing import Optional, List
2+
from fastapi import APIRouter, Depends, Request, Response
33
from fastapi.templating import Jinja2Templates
4-
from utils.core.dependencies import get_user_with_relations
5-
from utils.core.models import User
4+
from sqlmodel import Session, select
5+
from utils.core.dependencies import get_user_with_relations, get_session
6+
from utils.core.models import User, Organization
7+
from utils.core.enums import ValidPermissions
8+
from utils.app.models import OrganizationResource
69

710
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
811
templates = Jinja2Templates(directory="templates")
@@ -14,10 +17,85 @@
1417
@router.get("/")
1518
async def read_dashboard(
1619
request: Request,
17-
user: Optional[User] = Depends(get_user_with_relations)
20+
user: User = Depends(get_user_with_relations),
21+
session: Session = Depends(get_session),
1822
):
23+
organizations = user.organizations
24+
selected_org: Optional[Organization] = None
25+
resources: List[OrganizationResource] = []
26+
can_read = False
27+
can_write = False
28+
can_delete = False
29+
30+
if organizations:
31+
# Read selected org from cookie, fall back to first org
32+
selected_org_id_str = request.cookies.get("selected_organization_id")
33+
if selected_org_id_str:
34+
try:
35+
selected_org_id = int(selected_org_id_str)
36+
selected_org = next(
37+
(o for o in organizations if o.id == selected_org_id), None
38+
)
39+
except ValueError:
40+
pass
41+
42+
if not selected_org:
43+
selected_org = organizations[0]
44+
45+
# Load organization resources for the selected org
46+
if selected_org and selected_org.id is not None:
47+
resources = list(session.exec(
48+
select(OrganizationResource)
49+
.where(OrganizationResource.organization_id == selected_org.id)
50+
.order_by(OrganizationResource.created_at.desc()) # type: ignore[union-attr]
51+
).all())
52+
can_read = user.has_permission(
53+
ValidPermissions.READ_ORGANIZATION_RESOURCES, selected_org
54+
)
55+
can_write = user.has_permission(
56+
ValidPermissions.WRITE_ORGANIZATION_RESOURCES, selected_org
57+
)
58+
can_delete = user.has_permission(
59+
ValidPermissions.DELETE_ORGANIZATION_RESOURCES, selected_org
60+
)
61+
1962
return templates.TemplateResponse(
2063
request,
2164
"dashboard/index.html",
22-
{"user": user}
23-
)
65+
{
66+
"user": user,
67+
"organizations": organizations,
68+
"selected_org": selected_org,
69+
"resources": resources,
70+
"can_read": can_read,
71+
"can_write": can_write,
72+
"can_delete": can_delete,
73+
}
74+
)
75+
76+
77+
@router.post("/select-organization/{org_id}")
78+
async def select_organization(
79+
request: Request,
80+
org_id: int,
81+
user: User = Depends(get_user_with_relations),
82+
):
83+
"""Set the selected organization cookie and redirect back to dashboard."""
84+
# Verify user is a member of this organization
85+
org = next((o for o in user.organizations if o.id == org_id), None)
86+
if not org:
87+
# Fall back to dashboard without changing cookie
88+
response = Response(status_code=200)
89+
response.headers["HX-Redirect"] = str(request.url_for("read_dashboard"))
90+
return response
91+
92+
response = Response(status_code=200)
93+
response.set_cookie(
94+
key="selected_organization_id",
95+
value=str(org_id),
96+
httponly=True,
97+
samesite="strict",
98+
max_age=60 * 60 * 24 * 365, # 1 year
99+
)
100+
response.headers["HX-Redirect"] = str(request.url_for("read_dashboard"))
101+
return response

static/js/app.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// app.js — global utilities loaded with defer in <head>.
2+
// Because this script lives in <head> (outside <body>), it is never
3+
// re-processed during htmx hx-boost body swaps, which means the event
4+
// listeners registered here persist across page navigations.
5+
6+
function showToast(message, level) {
7+
level = level || 'success';
8+
var container = document.getElementById('toast-container');
9+
var wrapper = document.createElement('div');
10+
wrapper.className = 'toast align-items-center text-bg-' + level + ' border-0 show';
11+
wrapper.setAttribute('role', 'alert');
12+
wrapper.setAttribute('aria-atomic', 'true');
13+
wrapper.innerHTML =
14+
'<div class="d-flex">' +
15+
'<div class="toast-body">' + message + '</div>' +
16+
'<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>' +
17+
'</div>';
18+
container.appendChild(wrapper);
19+
setTimeout(function() { wrapper.remove(); }, 5000);
20+
}
21+
22+
// For HTMX error responses, extract and apply OOB swaps (toasts) without
23+
// touching the main target. We parse the response HTML, find elements with
24+
// hx-swap-oob, and swap them in manually via htmx.process().
25+
document.body.addEventListener('htmx:beforeSwap', function(evt) {
26+
if (evt.detail.xhr.status >= 400) {
27+
evt.detail.shouldSwap = false;
28+
evt.detail.isError = false;
29+
var responseText = evt.detail.xhr.responseText;
30+
if (responseText) {
31+
var doc = new DOMParser().parseFromString(responseText, 'text/html');
32+
var oobElements = doc.querySelectorAll('[hx-swap-oob]');
33+
oobElements.forEach(function(el) {
34+
var targetId = el.getAttribute('id');
35+
if (targetId) {
36+
var existing = document.getElementById(targetId);
37+
if (existing) {
38+
existing.replaceWith(el);
39+
htmx.process(el);
40+
}
41+
}
42+
});
43+
}
44+
}
45+
});
46+
47+
// Read flash cookie on page load
48+
(function() {
49+
var raw = document.cookie.split('; ').find(function(c) { return c.startsWith('flash_message='); });
50+
if (!raw) return;
51+
var value = decodeURIComponent(raw.split('=').slice(1).join('='));
52+
document.cookie = 'flash_message=; Max-Age=0; path=/';
53+
try {
54+
var flash = JSON.parse(value);
55+
if (flash && flash.message) showToast(flash.message, flash.level);
56+
} catch(e) {}
57+
})();
58+
59+
// Global handler: when a server response includes HX-Trigger: modalDismiss,
60+
// clean up any Bootstrap modal backdrop left behind by OOB swaps that
61+
// replaced the modal element before afterRequest could call .hide().
62+
document.body.addEventListener('modalDismiss', function() {
63+
document.querySelectorAll('.modal-backdrop').forEach(function(el) { el.remove(); });
64+
document.body.classList.remove('modal-open');
65+
document.body.style.removeProperty('overflow');
66+
document.body.style.removeProperty('padding-right');
67+
});

templates/base.html

Lines changed: 7 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@
1515
<link rel="mask-icon" href="{{ url_for('static', path='safari-pinned-tab.svg') }}" color="#5bbad5">
1616
<meta name="msapplication-TileColor" content="#da532c">
1717
<meta name="theme-color" content="#ffffff">
18+
<!-- Scripts in <head> with defer so they are NOT re-processed during
19+
htmx hx-boost body swaps. This preserves Bootstrap's document-level
20+
event delegation across AJAX navigations. defer maintains execution
21+
order and waits for DOM parsing to complete. -->
22+
<script defer src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
23+
<script defer src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script>
24+
<script defer src="{{ url_for('static', path='js/app.js') }}"></script>
1825
{% block extra_head %}{% endblock %}
1926
</head>
2027
<body class="min-vh-100 d-flex flex-column">
@@ -39,70 +46,6 @@
3946
{% include 'base/partials/footer.html' %}
4047
</footer>
4148

42-
<!-- Common scripts -->
43-
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
44-
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script>
45-
<script>
46-
function showToast(message, level) {
47-
level = level || 'success';
48-
var container = document.getElementById('toast-container');
49-
var wrapper = document.createElement('div');
50-
wrapper.className = 'toast align-items-center text-bg-' + level + ' border-0 show';
51-
wrapper.setAttribute('role', 'alert');
52-
wrapper.setAttribute('aria-atomic', 'true');
53-
wrapper.innerHTML =
54-
'<div class="d-flex">' +
55-
'<div class="toast-body">' + message + '</div>' +
56-
'<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>' +
57-
'</div>';
58-
container.appendChild(wrapper);
59-
setTimeout(function() { wrapper.remove(); }, 5000);
60-
}
61-
// For HTMX error responses, extract and apply OOB swaps (toasts) without
62-
// touching the main target. We parse the response HTML, find elements with
63-
// hx-swap-oob, and swap them in manually via htmx.process().
64-
document.body.addEventListener('htmx:beforeSwap', function(evt) {
65-
if (evt.detail.xhr.status >= 400) {
66-
evt.detail.shouldSwap = false;
67-
evt.detail.isError = false;
68-
var responseText = evt.detail.xhr.responseText;
69-
if (responseText) {
70-
var doc = new DOMParser().parseFromString(responseText, 'text/html');
71-
var oobElements = doc.querySelectorAll('[hx-swap-oob]');
72-
oobElements.forEach(function(el) {
73-
var targetId = el.getAttribute('id');
74-
if (targetId) {
75-
var existing = document.getElementById(targetId);
76-
if (existing) {
77-
existing.replaceWith(el);
78-
htmx.process(el);
79-
}
80-
}
81-
});
82-
}
83-
}
84-
});
85-
// Read flash cookie on page load
86-
(function() {
87-
var raw = document.cookie.split('; ').find(function(c) { return c.startsWith('flash_message='); });
88-
if (!raw) return;
89-
var value = decodeURIComponent(raw.split('=').slice(1).join('='));
90-
document.cookie = 'flash_message=; Max-Age=0; path=/';
91-
try {
92-
var flash = JSON.parse(value);
93-
if (flash && flash.message) showToast(flash.message, flash.level);
94-
} catch(e) {}
95-
})();
96-
// Global handler: when a server response includes HX-Trigger: modalDismiss,
97-
// clean up any Bootstrap modal backdrop left behind by OOB swaps that
98-
// replaced the modal element before afterRequest could call .hide().
99-
document.body.addEventListener('modalDismiss', function() {
100-
document.querySelectorAll('.modal-backdrop').forEach(function(el) { el.remove(); });
101-
document.body.classList.remove('modal-open');
102-
document.body.style.removeProperty('overflow');
103-
document.body.style.removeProperty('padding-right');
104-
});
105-
</script>
10649
{% block extra_scripts %}{% endblock %}
10750
</body>
10851
</html>

templates/dashboard/index.html

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,61 @@
11
{% extends "base.html" %}
22

33
{% block content %}
4-
<div class="dashboard">
5-
<!-- Dashboard Components -->
4+
<div class="dashboard container py-4">
5+
6+
{% if organizations %}
7+
<!-- Organization Selector -->
8+
<div class="d-flex align-items-center mb-4">
9+
<label for="orgSelect" class="form-label me-2 mb-0 fw-bold">Organization:</label>
10+
<div class="dropdown">
11+
<button class="btn btn-outline-primary dropdown-toggle" type="button" id="orgSelect" data-bs-toggle="dropdown" aria-expanded="false">
12+
{{ selected_org.name if selected_org else "Select Organization" }}
13+
</button>
14+
<ul class="dropdown-menu" aria-labelledby="orgSelect">
15+
{% for org in organizations %}
16+
<li>
17+
<button class="dropdown-item {% if selected_org and org.id == selected_org.id %}active{% endif %}"
18+
hx-post="{{ url_for('select_organization', org_id=org.id) }}"
19+
hx-swap="none">
20+
{{ org.name }}
21+
</button>
22+
</li>
23+
{% endfor %}
24+
</ul>
25+
</div>
26+
</div>
27+
{% endif %}
28+
29+
<!-- Organization Resources Section -->
30+
<div class="card">
31+
<div class="card-header">
32+
<h5 class="mb-0">Organization-specific assets will display here:</h5>
33+
</div>
34+
<div class="card-body">
35+
{% if not organizations %}
36+
<p class="text-muted">You are not a member of any organizations. Create or join an organization to see resources here.</p>
37+
{% elif not can_read %}
38+
<p class="text-muted">You do not have permission to view resources for this organization.</p>
39+
{% elif resources %}
40+
<!-- Replace this example resource list with your own application UI -->
41+
<div class="list-group">
42+
{% for resource in resources %}
43+
<div class="list-group-item">
44+
<div class="d-flex w-100 justify-content-between">
45+
<h6 class="mb-1">{{ resource.title }}</h6>
46+
<small class="text-muted">{{ resource.created_at.strftime('%Y-%m-%d') }}</small>
47+
</div>
48+
{% if resource.description %}
49+
<p class="mb-1 text-muted">{{ resource.description }}</p>
50+
{% endif %}
51+
</div>
52+
{% endfor %}
53+
</div>
54+
{% else %}
55+
<p class="text-muted">No resources found for this organization.</p>
56+
{% endif %}
57+
</div>
58+
</div>
59+
660
</div>
7-
{% endblock %}
61+
{% endblock %}

0 commit comments

Comments
 (0)