diff --git a/auth/MANIFEST.in b/auth/MANIFEST.in index 16688ad841a..fe8d7b8c4df 100644 --- a/auth/MANIFEST.in +++ b/auth/MANIFEST.in @@ -1 +1,2 @@ recursive-include auth/templates * +recursive-include auth/static * diff --git a/auth/auth/auth.py b/auth/auth/auth.py index a5447aabd74..d176a719a20 100644 --- a/auth/auth/auth.py +++ b/auth/auth/auth.py @@ -9,6 +9,7 @@ from urllib.parse import urlparse import aiohttp_session +import jinja2 import kubernetes_asyncio.client import kubernetes_asyncio.client.rest import kubernetes_asyncio.config @@ -67,6 +68,8 @@ CLOUD = get_global_config()['cloud'] DEFAULT_NAMESPACE = os.environ['HAIL_DEFAULT_NAMESPACE'] +INACTIVE_USER_TIMEOUT_DAYS = int(os.environ.get('INACTIVE_USER_TIMEOUT_DAYS', '60')) +AUTH_ROOT = os.path.dirname(os.path.abspath(__file__)) is_test_deployment = DEFAULT_NAMESPACE != 'default' @@ -219,9 +222,7 @@ def validate_next_page_url(next_page): actual_next_page_domain = urlparse(next_page).netloc if actual_next_page_domain not in valid_next_domains: - raise web.HTTPBadRequest( - text=f'Invalid next page: \'{next_page}\'. Domain \'{actual_next_page_domain}\' not in {valid_next_domains}' - ) + raise web.HTTPBadRequest(text='Invalid next page.') @routes.get('/healthcheck') @@ -243,6 +244,22 @@ async def openapi(request): return await render_template('auth', request, None, 'openapi.yaml', page_context) +@routes.get('/auth/static/js/{filename}') +@web_security_headers +async def fetch_js_file(request): + filename = request.match_info['filename'] + if filename.endswith('.js'): + page_context = {'base_path': deploy_config.base_path('auth')} + try: + response = await render_template('auth', request, None, f'js/{filename}', page_context) + response.content_type = 'application/javascript' + return response + except jinja2.exceptions.TemplateNotFound as error: + raise web.HTTPNotFound() from error + else: + raise web.HTTPNotFound() + + @routes.get('') @routes.get('/') @web_security_headers @@ -271,7 +288,12 @@ async def creating_account(request: web.Request, userdata: Optional[UserData]) - set_message(session, f'Account does not exist for login id {login_id}.', 'error') raise web.HTTPFound(deploy_config.external_url('auth', '')) - page_context = {'username': user['username'], 'state': user['state'], 'login_id': user['login_id']} + page_context = { + 'username': user['username'], + 'state': user['state'], + 'login_id': user['login_id'], + 'inactive_timeout_days': INACTIVE_USER_TIMEOUT_DAYS, + } if user['state'] in ('deleting', 'deleted'): return await render_template('auth', request, userdata, 'account-error.html', page_context) @@ -422,9 +444,16 @@ async def callback(request) -> web.Response: raise web.HTTPFound(creating_url) - if user['state'] in ('deleting', 'deleted'): - page_context = {'username': user['username'], 'state': user['state'], 'login_id': user['login_id']} - return await render_template('auth', request, user, 'account-error.html', page_context) + if user['state'] in ('deleting', 'deleted', 'inactive'): + page_context = { + 'username': user['username'], + 'state': user['state'], + 'login_id': user['login_id'], + 'inactive_timeout_days': INACTIVE_USER_TIMEOUT_DAYS, + } + return await render_template( + 'auth', request, user, 'account-error.html', page_context, status_code=web.HTTPUnauthorized.status_code + ) if user['state'] == 'creating': if caller == 'signup': @@ -1239,7 +1268,7 @@ def run(): ] ) - setup_aiohttp_jinja2(app, 'auth') + setup_aiohttp_jinja2(app, 'auth', jinja2.FileSystemLoader(f'{AUTH_ROOT}/static/')) setup_aiohttp_session(app) setup_common_static_routes(routes) diff --git a/auth/auth/static/js/account-creating.js b/auth/auth/static/js/account-creating.js new file mode 100644 index 00000000000..ea518d93414 --- /dev/null +++ b/auth/auth/static/js/account-creating.js @@ -0,0 +1,5 @@ +let protocol = location.protocol.replace("http", "ws") +let sock = new WebSocket(protocol + location.host + "{{ base_path }}/creating/wait"); +sock.onmessage = function (_event) { + window.location.reload() +} diff --git a/auth/auth/static/js/roles.js b/auth/auth/static/js/roles.js new file mode 100644 index 00000000000..36a5cd63577 --- /dev/null +++ b/auth/auth/static/js/roles.js @@ -0,0 +1,73 @@ +const csrfToken = document.head.querySelector('meta[name="csrf"]')?.getAttribute('value') || ''; + +document.getElementsByName('remove-role-button').forEach(button => { + button.addEventListener('click', (_e) => { removeRole(button.dataset.user, button.dataset.role); }) +}); + +document.getElementsByName('add-role-button').forEach(button => { + button.addEventListener('click', (_e) => { addRole(button.dataset['username-input-id'], button.dataset.role); }) +}); + +async function removeRole(username, roleName) { + if (!confirm(`Are you sure you want to remove the '${roleName}' role from user '${username}'?`)) { + return; + } + + try { + const response = await fetch("{{ base_path }}/api/v1alpha/system_roles/${username}", { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + role_removal: roleName + }) + }); + + if (response.ok) { + // Refresh the page to show updated data + window.location.reload(); + } else { + const errorData = await response.json().catch(() => ({})); + alert(`Failed to remove role: ${errorData.message || response.statusText}`); + } + } catch (error) { + console.error('Error removing role:', error); + alert('Failed to remove role. Please try again.'); + } +} + +async function addRole(usernameInputId, roleName) { + const username = document.getElementById(usernameInputId).value.trim(); + + if (!username) { + alert('Please enter a username'); + return; + } + + try { + const response = await fetch("{{ base_path }}/api/v1alpha/system_roles/${username}", { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + role_addition: roleName + }) + }); + + if (response.ok) { + // Clear the input and refresh the page + document.getElementById(usernameInputId).value = ''; + window.location.reload(); + } else { + const errorData = await response.json().catch(() => ({})); + alert(`Failed to add role: ${errorData.message || response.statusText}`); + } + } catch (error) { + console.error('Error adding role:', error); + alert('Failed to add role. Please try again.'); + } +} diff --git a/auth/auth/static/js/user.js b/auth/auth/static/js/user.js new file mode 100644 index 00000000000..2d9298b9d1a --- /dev/null +++ b/auth/auth/static/js/user.js @@ -0,0 +1,23 @@ +const tosCheckbox = document.getElementById('tosCheckbox'); +tosCheckbox.addEventListener('load', (_e) => { updateButtons(); }) +tosCheckbox.addEventListener('change', (_e) => { updateButtons(); }) + +function updateButtons() { + const checkbox = document.getElementById('tosCheckbox'); + const signupButton = document.getElementById('signupButton'); + const loginButton = document.getElementById('loginButton'); + + signupButton.disabled = !checkbox.checked; + loginButton.disabled = !checkbox.checked; + + // Update button styles based on state + [signupButton, loginButton].forEach(button => { + if (button.disabled) { + button.classList.add('opacity-50', 'cursor-not-allowed'); + button.classList.remove('hover:bg-blue-700'); + } else { + button.classList.remove('opacity-50', 'cursor-not-allowed'); + button.classList.add('hover:bg-blue-700'); + } + }); +} diff --git a/auth/auth/static/js/users.js b/auth/auth/static/js/users.js new file mode 100644 index 00000000000..ebee6818611 --- /dev/null +++ b/auth/auth/static/js/users.js @@ -0,0 +1,35 @@ +const showActiveCheckbox = document.getElementById('show-active'); +showActiveCheckbox.addEventListener('change', (_e) => { filterTable(); }) +const showDevelopersCheckbox = document.getElementById('show-developers'); +showDevelopersCheckbox.addEventListener('change', (_e) => { filterTable(); }) + +const reactivateUserForm = document.getElementById('reactivate-user-form'); +reactivateUserForm.addEventListener('submit', (_e) => { validateReactivateForm(); }) + +function filterTable() { + const showActive = document.getElementById('show-active').checked; + const showDevelopers = document.getElementById('show-developers').checked; + + const rows = document.querySelectorAll('#users-table tbody tr'); + + rows.forEach(row => { + const state = row.querySelector('[data-state]').dataset.state.trim(); + const isDeveloper = row.querySelector('[data-developer]').dataset.developer.trim(); + + let show = true; + if (showActive && state !== 'active') show = false; + if (showDevelopers && isDeveloper !== '1') show = false; + + row.style.display = show ? '' : 'none'; + }); +} +function validateReactivateForm() { + const userId = document.getElementById('inactive-id').value.trim(); + const username = document.getElementById('inactive-username').value.trim(); + + if (!userId && !username) { + alert('Please provide either a User ID or a Username.'); + return false; + } + return true; +} diff --git a/auth/auth/templates/account-creating.html b/auth/auth/templates/account-creating.html index e4fad1f0d4a..45dd8308aa7 100644 --- a/auth/auth/templates/account-creating.html +++ b/auth/auth/templates/account-creating.html @@ -5,11 +5,7 @@
Creating account for {{ username }} - {% endblock %} diff --git a/auth/auth/templates/account-error.html b/auth/auth/templates/account-error.html index b7845aaa6e4..e9e53ea4a1e 100644 --- a/auth/auth/templates/account-error.html +++ b/auth/auth/templates/account-error.html @@ -2,14 +2,33 @@ {% block title %}Account Error{% endblock %} {% block content %}You must sign up or log in to continue
{% endif %} - +diff --git a/auth/auth/templates/users.html b/auth/auth/templates/users.html index 88fc7e7383e..7e2baf60006 100644 --- a/auth/auth/templates/users.html +++ b/auth/auth/templates/users.html @@ -37,7 +37,7 @@