patx/gitman

Align header icons and move password changes to profile

Commit 00c32ec · patx · 2026-11-02T23:58:02-05:00

Changeset
00c32ecf9f8de66d4520fdfb6708378d83b04a11
Parents
26ceefa19e837fce55ffbf960745f5caeccb16e7

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/app.py b/app.py
index 4ef38d2..7d94337 100644
--- a/app.py
+++ b/app.py
@@ -4995,7 +4995,7 @@ def change_password():
     confirmation = request.forms.get("confirm_password", "")
     def invalid(message, status=400):
         response.status = status
-        return render("edit_profile.tpl", profile=user, password_error=message)
+        return render_user_profile(user, password_error=message)
     if len(current) > 1024 or not verify_password(current, user["password_hash"]):
         record_auth_failure("password-change", identifier)
         return invalid("Current password is incorrect.")
@@ -5017,7 +5017,7 @@ def change_password():
     refreshed = get_user_by_id(user["id"])
     request.environ["gitman.user"] = refreshed
     login_user(refreshed)
-    return render("edit_profile.tpl", profile=refreshed, notice="Password changed. Other browser sessions are signed out.")
+    return render_user_profile(refreshed, notice="Password changed. Other browser sessions are signed out.")
 
 
 @app.route("/new", method=["GET", "POST"])
@@ -5041,6 +5041,10 @@ def user_profile(username):
     profile_user = get_user_by_username(username.lower())
     if not profile_user:
         abort(404, "User not found.")
+    return render_user_profile(profile_user)
+
+
+def render_user_profile(profile_user, **context):
     user = current_user()
     active_tab = request.query.get("tab", "owned")
     if active_tab not in {"owned", "stars"}:
@@ -5065,6 +5069,8 @@ def user_profile(username):
         active_tab=active_tab,
         is_self=bool(user and user["id"] == profile_user["id"]),
         pagination=pagination_metadata(page, per_page, total=total),
+        current_url_with_page=lambda next_page: f"/{profile_user['username']}?" + urlencode({"tab": active_tab, "page": next_page}),
+        **context,
     )
 
 
diff --git a/static/styles.css b/static/styles.css
index c2e0bd8..2230f2f 100644
--- a/static/styles.css
+++ b/static/styles.css
@@ -183,15 +183,19 @@ button, .button {
   transform: scale(2);
   transform-origin: center;
 }
+.site-header, .nav { align-items: center; }
 .nav-icons {
   display: inline-flex;
   align-items: center;
-  color: black;
+  justify-content: center;
+  width: 24px;
+  height: 24px;
   font-weight: 700;
   line-height: 1;
   text-decoration: none;
   color: var(--muted);
 }
+.nav-icons svg { display: block; width: 24px; height: 24px; flex-shrink: 0; }
 .nav form, .repo-tabs form, .inline-form { display: inline; }
 .link-button { padding: 0; color: var(--link); background: none; border: 0; text-decoration: underline; }
 .repo-tabs .repo-tab { padding-bottom: .2rem; border-bottom: 2px solid transparent; }
@@ -380,7 +384,6 @@ button, .button {
   .grid.two, .commit-list li, .tag-list li { grid-template-columns: 1fr; }
 }
 
-.profile-security { margin: 1.5rem 0; }
 .password-dialog {
   width: min(28rem, calc(100% - 2rem));
   max-height: calc(100dvh - 2rem);
diff --git a/templates/base.tpl b/templates/base.tpl
index 1d83905..9f733e4 100644
--- a/templates/base.tpl
+++ b/templates/base.tpl
@@ -9,7 +9,7 @@
 <link rel="apple-touch-icon" sizes="180x180" href="/static/git.svg">
   <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
   <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" media="(prefers-color-scheme: dark)">
-  <link rel="stylesheet" href="/static/styles.css?v=6">
+  <link rel="stylesheet" href="/static/styles.css?v=7">
 </head>
 <body>
   <header class="site-header">
@@ -29,17 +29,17 @@
             <path d="M8 16a2 2 0 0 0 2-2H6a2 2 0 0 0 2 2M8 1.918l-.797.161A4 4 0 0 0 4 6c0 .628-.134 2.197-.459 3.742-.16.767-.376 1.566-.663 2.258h10.244c-.287-.692-.502-1.49-.663-2.258C12.134 8.197 12 6.628 12 6a4 4 0 0 0-3.203-3.92zM14.22 12c.223.447.481.801.78 1H1c.299-.199.557-.553.78-1C2.68 10.2 3 6.88 3 6c0-2.42 1.72-4.44 4.005-4.901a1 1 0 1 1 1.99 0A5 5 0 0 1 13 6c0 .88.32 4.2 1.22 6"/>
           </svg>
         </a>
-        <a class="nav-icons" href="/{{user['username']}}">
-            <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-person" viewBox="0 0 16 16">
-                <path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6m2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0m4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4m-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10s-3.516.68-4.168 1.332c-.678.678-.83 1.418-.832 1.664z"/>
-            </svg>
-        </a>
-        <a class="nav-icons" href="/new">
-            <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-plus-square" viewBox="0 0 16 16">
+        <a class="nav-icons" href="/new" aria-label="New repository" title="New repository">
+            <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-plus-square" viewBox="0 0 16 16" aria-hidden="true">
               <path d="M14 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z"/>
               <path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4"/>
             </svg>
         </a>
+        <a class="nav-icons" href="/{{user['username']}}" aria-label="Profile" title="Profile">
+            <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-person" viewBox="2 2 12 12" aria-hidden="true">
+                <path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6m2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0m4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4m-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10s-3.516.68-4.168 1.332c-.678.678-.83 1.418-.832 1.664z"/>
+            </svg>
+        </a>
       % else:
         <a href="/login">Log in</a>
         <a class="button small" href="/signup">Sign up</a>
diff --git a/templates/edit_profile.tpl b/templates/edit_profile.tpl
index d4ef85d..c04ae87 100644
--- a/templates/edit_profile.tpl
+++ b/templates/edit_profile.tpl
@@ -18,32 +18,5 @@
     </label>
     <button class="button" type="submit">Save profile</button>
   </form>
-  <p class="profile-security"><button type="button" id="change-password-open">Change password</button></p>
   <p class="muted"><a href="/{{profile['username']}}"><- Back</a></p>
 </section>
-
-<dialog id="password-dialog" class="password-dialog" aria-labelledby="password-title" data-reopen="{{'true' if get('password_error') else 'false'}}">
-  <h2 id="password-title">Change password</h2>
-  <form method="post" action="/settings/password">
-    {{!csrf_field()}}
-    <input type="text" name="username" value="{{user['username']}}" autocomplete="username" hidden>
-    % if get('password_error'):
-      <p class="alert" role="alert">{{password_error}}</p>
-    % end
-    <label>Current password
-      <input type="password" name="current_password" autocomplete="current-password" maxlength="1024" required autofocus>
-    </label>
-    <label>New password
-      <input type="password" name="new_password" autocomplete="new-password" minlength="8" maxlength="1024" required aria-describedby="password-hint">
-    </label>
-    <label>Confirm new password
-      <input type="password" name="confirm_password" autocomplete="new-password" minlength="8" maxlength="1024" required>
-    </label>
-    <p class="muted" id="password-hint">At least 8 characters. Other browser sessions will be signed out.</p>
-    <div class="password-actions">
-      <button type="button" id="change-password-cancel">Cancel</button>
-      <button type="submit">Change password</button>
-    </div>
-  </form>
-</dialog>
-<script src="/static/profile.js?v=1" defer></script>
diff --git a/templates/password_dialog.tpl b/templates/password_dialog.tpl
new file mode 100644
index 0000000..0b5da4c
--- /dev/null
+++ b/templates/password_dialog.tpl
@@ -0,0 +1,25 @@
+<dialog id="password-dialog" class="password-dialog" aria-labelledby="password-title" data-reopen="{{'true' if get('password_error') else 'false'}}">
+  <h2 id="password-title">Change password</h2>
+  <form method="post" action="/settings/password">
+    {{!csrf_field()}}
+    <input type="text" name="username" value="{{user['username']}}" autocomplete="username" hidden>
+    % if get('password_error'):
+      <p class="alert" role="alert">{{password_error}}</p>
+    % end
+    <label>Current password
+      <input type="password" name="current_password" autocomplete="current-password" maxlength="1024" required autofocus>
+    </label>
+    <label>New password
+      <input type="password" name="new_password" autocomplete="new-password" minlength="8" maxlength="1024" required aria-describedby="password-hint">
+    </label>
+    <label>Confirm new password
+      <input type="password" name="confirm_password" autocomplete="new-password" minlength="8" maxlength="1024" required>
+    </label>
+    <p class="muted" id="password-hint">At least 8 characters. Other browser sessions will be signed out.</p>
+    <div class="password-actions">
+      <button type="button" id="change-password-cancel">Cancel</button>
+      <button type="submit">Change password</button>
+    </div>
+  </form>
+</dialog>
+<script src="/static/profile.js?v=1" defer></script>
diff --git a/templates/profile.tpl b/templates/profile.tpl
index 1cfd365..25f1ef0 100644
--- a/templates/profile.tpl
+++ b/templates/profile.tpl
@@ -12,7 +12,7 @@
   <p class="muted"><small>
     Joined {{profile_user["created_at"]}}
     % if is_self:
-      <a class="button-link" href="/settings/profile">Edit profile</a> &middot; <a class="button-link" href="/logout">Log out</a>
+      <a class="button-link" href="/settings/profile">Edit profile</a> &middot; <button class="link-button" type="button" id="change-password-open">Change password</button> &middot; <a class="button-link" href="/logout">Log out</a>
     % end
   </small></p>
   % if profile_user["bio"]:
@@ -54,3 +54,7 @@
     % include("pagination.tpl", pagination=pagination)
   % end
 </section>
+
+% if is_self:
+  % include("password_dialog.tpl")
+% end
diff --git a/tests/test_app.py b/tests/test_app.py
index d4c1199..2ad1776 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -2702,10 +2702,16 @@ def test_change_password_revokes_other_sessions_and_git_credentials(isolated_app
         assert login_client(browser, 'alice').status_code == 303
     legacy.cookies['session'] = cookie_encode(('session', str(owner['id'])), gitman.SECRET_KEY).decode()
     assert legacy.get('/settings/profile').status_code == 200
-    client.get('/settings/profile')
+    profile = client.get('/alice').text
+    assert 'id="change-password-open"' in profile
+    assert profile.index('aria-label="Notifications"') < profile.index('aria-label="New repository"') < profile.index('aria-label="Profile"')
+    assert 'id="change-password-open"' not in WsgiClient(isolated_app.app).get('/alice').text
+    assert 'id="change-password-open"' not in client.get('/settings/profile').text
     result = client.post('/settings/password', {'current_password': 'correct horse battery staple',
         'new_password': 'a different secure password', 'confirm_password': 'a different secure password'})
     assert result.status_code == 200 and 'Password changed.' in result.text
+    assert 'id="change-password-open"' in result.text
+    assert '<h1>Edit profile</h1>' not in result.text
     assert client.get('/settings/profile').status_code == 200
     for browser in (other, legacy):
         assert browser.get('/settings/profile').status_code == 303
@@ -2732,6 +2738,8 @@ def test_change_password_rejects_invalid_input_without_changing_credentials(isol
     result = client.post('/settings/password', {'current_password':current, 'new_password':new, 'confirm_password':confirmation})
     assert result.status_code == 400
     assert 'data-reopen="true"' in result.text
+    assert 'id="change-password-open"' in result.text
+    assert '<h1>Edit profile</h1>' not in result.text
     assert 'value="'+new+'"' not in result.text
     assert isolated_app.get_user_by_id(owner['id'])['password_hash'] == owner['password_hash']