✨ (profile): Add public person profile page and de-duplicate search box
Changes
12 files changed, +208 -12
MODIFY
README.md
+4 -2
@@ -49,8 +49,10 @@
49
49
- **Search** — a simple case-insensitive substring search over repositories (owner, name,
50
50
description) and people (username, display name), from the header search box or `GET /search`,
51
51
and as JSON at `GET /api/v1/search?q=<term>`. Repository hits obey the same visibility rule as
52
- everywhere else, so a private repo never surfaces to someone who cannot already read it. Guides:
53
- [for users](docs/users/search.md), [for admins](docs/admins/search.md)
52
+ everywhere else, so a private repo never surfaces to someone who cannot already read it. Person
53
+ hits link to that user's public profile page at `GET /users/{username}` (avatar, display name,
54
+ and their visibility-filtered repositories — the person equivalent of the `/orgs/{name}`
55
+ organisation profile). Guides: [for users](docs/users/search.md), [for admins](docs/admins/search.md)
54
56
- Single access policy on all paths: owner read/write, collaborators read/write, org roles
55
57
(guest read / member write / owner admin) on org repositories, public world-readable,
56
58
private repositories visible only to whoever holds a read grant
MODIFY
docs/users/profile.md
+19 -0
@@ -80,3 +80,22 @@
80
80
Your uploaded picture is served publicly at `/users/<your-username>/avatar` so
81
81
it can be embedded on public pages without requiring the viewer to be logged
82
82
in.
83
+
84
+---
85
+
86
+## Public profile page
87
+
88
+Everyone — including visitors who aren't logged in — has a public profile page
89
+at `/users/<username>`. It's what you land on when you click a person's name
90
+from a [search](search.md) hit.
91
+
92
+It shows:
93
+
94
+- The person's avatar and display name (or their username, if they haven't
95
+ set one).
96
+- A list of the repositories they own — filtered to what **you** are allowed
97
+ to see: their public repositories, plus their private ones only if you
98
+ already have read access (owner, collaborator, or through an organisation).
99
+
100
+An unknown username shows a 404 page. This is the person equivalent of an
101
+[organisation](organisations.md)'s profile at `/orgs/<name>`.
MODIFY
docs/users/search.md
+6 -3
@@ -1,7 +1,9 @@
1
1
# Search
2
2
3
-git-shark has a single search box in the header, on every page. Type a term and
4
-press Enter to land on the results page at `/search?q=<term>`.
3
+git-shark has a single search box in the header, on every page — the results
4
+page has no search box of its own. Type a term and press Enter to land on the
5
+results page at `/search?q=<term>`; the header box stays prefilled with your
6
+term so you can see (and tweak) what you searched for.
5
7
6
8
## What it searches
7
9
@@ -21,7 +23,8 @@
21
23
22
24
- Repository hits link straight to the repository page and show the same
23
25
name, visibility badge, and description you see in repository lists.
24
-- People hits show the avatar, handle, and display name.
26
+- People hits show the avatar, handle, and display name, and link to that
27
+ person's [profile page](profile.md#public-profile-page).
25
28
26
29
You only ever see repositories you are allowed to see: **public repositories,
27
30
plus your own private ones** (and private repositories shared with you as a
MODIFY
src/main/java/de/workaround/git/GitRepositoryService.java
+5 -0
@@ -198,6 +198,11 @@
198
198
return repositories.findByOwnerOrg(organisation);
199
199
}
200
200
201
+ public List<Repository> listOwnedBy(User owner)
202
+ {
203
+ return repositories.findByOwnerUser(owner);
204
+ }
205
+
201
206
private static void validateName(String name)
202
207
{
203
208
if (name == null || name.isEmpty() || name.equals(".") || name.equals("..")
MODIFY
src/main/java/de/workaround/model/Repository.java
+3 -0
@@ -97,6 +97,9 @@
97
97
@HQL("where ownerOrg = :ownerOrg order by name")
98
98
List<Repository> findByOwnerOrg(Organisation ownerOrg);
99
99
100
+ @HQL("where ownerUser = :ownerUser order by name")
101
+ List<Repository> findByOwnerUser(User ownerUser);
102
+
100
103
@HQL("select count(r) from Repository r where r.ownerOrg = :ownerOrg")
101
104
long countByOwnerOrg(Organisation ownerOrg);
102
105
MODIFY
src/main/java/de/workaround/web/CurrentRequest.java
+10 -0
@@ -26,4 +26,14 @@
26
26
return "/login?redirect=" + URLEncoder.encode(routingContext.request().uri(), StandardCharsets.UTF_8);
27
27
}
28
28
29
+ /**
30
+ * The current {@code q} query parameter, or an empty string. Used to prefill the single search
31
+ * box in the header so the search results page needs no duplicate input of its own.
32
+ */
33
+ public String searchQuery()
34
+ {
35
+ String q = routingContext.request().getParam("q");
36
+ return q == null ? "" : q;
37
+ }
38
+
29
39
}
ADD
src/main/java/de/workaround/web/ProfileResource.java
+58 -0
@@ -0,0 +1,58 @@
1
+package de.workaround.web;
2
+
3
+import java.util.List;
4
+
5
+import de.workaround.account.CurrentUser;
6
+import de.workaround.git.AccessPolicy;
7
+import de.workaround.git.GitRepositoryService;
8
+import de.workaround.model.Repository;
9
+import de.workaround.model.User;
10
+import io.quarkus.qute.CheckedTemplate;
11
+import io.quarkus.qute.TemplateInstance;
12
+import jakarta.inject.Inject;
13
+import jakarta.ws.rs.GET;
14
+import jakarta.ws.rs.NotFoundException;
15
+import jakarta.ws.rs.Path;
16
+import jakarta.ws.rs.PathParam;
17
+import jakarta.ws.rs.Produces;
18
+import jakarta.ws.rs.core.MediaType;
19
+
20
+/**
21
+ * The public profile page for a person at {@code /users/{username}}: their display name and a
22
+ * visibility-filtered list of the repositories they own. Reached by clicking a person hit on the
23
+ * search results page. Organisation profiles live under {@code /orgs/{name}}.
24
+ */
25
+@Path("/users")
26
+@Produces(MediaType.TEXT_HTML)
27
+public class ProfileResource
28
+{
29
+ @CheckedTemplate
30
+ static class Templates
31
+ {
32
+ static native TemplateInstance profile(User profile, List<Repository> repos, User user);
33
+ }
34
+
35
+ @Inject
36
+ CurrentUser currentUser;
37
+
38
+ @Inject
39
+ GitRepositoryService repositories;
40
+
41
+ @Inject
42
+ AccessPolicy accessPolicy;
43
+
44
+ @Inject
45
+ User.Repo users;
46
+
47
+ @GET
48
+ @Path("{username}")
49
+ public TemplateInstance page(@PathParam("username") String username)
50
+ {
51
+ User profile = users.findByUsername(username).orElseThrow(NotFoundException::new);
52
+ User user = currentUser.get();
53
+ List<Repository> visible = repositories.listOwnedBy(profile).stream()
54
+ .filter(repo -> accessPolicy.canRead(user, repo))
55
+ .toList();
56
+ return Templates.profile(profile, visible, user);
57
+ }
58
+}
ADD
src/main/resources/templates/ProfileResource/profile.html
+22 -0
@@ -0,0 +1,22 @@
1
+{#include layout}
2
+{#title}{profile.username} – git-shark{/title}
3
+<h1>{#avatar user=profile /} {profile.displayName ?: profile.username}</h1>
4
+<p class="muted mono">{profile.username}</p>
5
+<section class="dashboard-section">
6
+ <h2>Repositories</h2>
7
+ {#if repos.isEmpty()}
8
+ <p class="muted empty-state">No repositories yet.</p>
9
+ {#else}
10
+ <table>
11
+ <tr><th>Repository</th><th>Visibility</th><th>Description</th></tr>
12
+ {#for repo in repos}
13
+ <tr class="row-link">
14
+ <td class="cell-link"><a class="mono" href="/repos/{repo.ownerHandle}/{repo.name}">{#repoAvatar repo=repo /} {repo.name}</a></td>
15
+ <td><span class="badge badge-{repo.visibility.name().toLowerCase()}">{repo.visibility.name().toLowerCase()}</span></td>
16
+ <td class="muted">{repo.description ?: ''}</td>
17
+ </tr>
18
+ {/for}
19
+ </table>
20
+ {/if}
21
+</section>
22
+{/include}
MODIFY
src/main/resources/templates/SearchResource/results.html
+1 -5
@@ -1,10 +1,6 @@
1
1
{#include layout}
2
2
{#title}Search – git-shark{/title}
3
3
<h1>Search</h1>
4
-<form class="search" method="get" action="/search">
5
- <input type="search" name="q" value="{query}" placeholder="Search repositories and people" aria-label="Search">
6
- <button class="btn btn-primary">Search</button>
7
-</form>
8
4
{#if query.isBlank()}
9
5
<p class="muted">Type a query to search repositories and people.</p>
10
6
{#else}
@@ -29,7 +25,7 @@
29
25
{#else}
30
26
<ul class="person-list">
31
27
{#for person in persons}
32
- <li>{#avatar user=person /} <span class="mono">{person.username}</span>{#if person.displayName} <span class="muted">{person.displayName}</span>{/if}</li>
28
+ <li><a href="/users/{person.username}">{#avatar user=person /} <span class="mono">{person.username}</span>{#if person.displayName} <span class="muted">{person.displayName}</span>{/if}</a></li>
33
29
{/for}
34
30
</ul>
35
31
{/if}
MODIFY
src/main/resources/templates/layout.html
+1 -1
@@ -12,7 +12,7 @@
12
12
<header class="site">
13
13
<a class="brand" href="/"><svg class="bmark" viewBox="0 0 64 64" width="26" height="26" aria-hidden="true"><rect width="64" height="64" rx="15" fill="#168b85"></rect><path d="M12 42 H52 M24 42 C29 42 31 50 38 50" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round"></path><circle cx="16" cy="42" r="3.2" fill="#fff"></circle><circle cx="38" cy="50" r="3.2" fill="#fff"></circle><path d="M26 42 C28 32 32 24 39 14 C40 22 42 33 45 42 Z" fill="#fff"></path></svg><span class="g">git</span><span class="s">shark</span></a>
14
14
<form class="nav-search" method="get" action="/search" role="search">
15
- <input type="search" name="q" placeholder="Search" aria-label="Search repositories and people">
15
+ <input type="search" name="q" value="{cdi:currentRequest.searchQuery}" placeholder="Search" aria-label="Search repositories and people">
16
16
</form>
17
17
<nav>
18
18
{#insert nav}
ADD
src/test/java/de/workaround/web/ProfileUiTest.java
+64 -0
@@ -0,0 +1,64 @@
1
+package de.workaround.web;
2
+
3
+import java.util.UUID;
4
+
5
+import org.junit.jupiter.api.Test;
6
+
7
+import de.workaround.git.GitRepositoryService;
8
+import de.workaround.model.Repository;
9
+import de.workaround.model.User;
10
+import io.quarkus.test.junit.QuarkusTest;
11
+import jakarta.inject.Inject;
12
+import jakarta.transaction.Transactional;
13
+
14
+import static io.restassured.RestAssured.given;
15
+import static org.hamcrest.CoreMatchers.containsString;
16
+import static org.hamcrest.CoreMatchers.not;
17
+
18
+@QuarkusTest
19
+class ProfileUiTest
20
+{
21
+ @Inject
22
+ GitRepositoryService service;
23
+
24
+ @Inject
25
+ User.Repo userRepo;
26
+
27
+ @Test
28
+ void profilePageListsOwnersVisibleRepos()
29
+ {
30
+ String tok = "prof" + shortId();
31
+ User owner = persistUser("person-" + tok, "Person " + tok);
32
+ service.create(owner, "pub-" + tok, Repository.Visibility.PUBLIC, "a public one");
33
+ service.create(owner, "sec-" + tok, Repository.Visibility.PRIVATE, null);
34
+
35
+ given().when().get("/users/person-" + tok)
36
+ .then().statusCode(200)
37
+ .body(containsString("Person " + tok))
38
+ .body(containsString("/repos/person-" + tok + "/pub-" + tok))
39
+ .body(not(containsString("sec-" + tok)));
40
+ }
41
+
42
+ @Test
43
+ void unknownUserReturns404()
44
+ {
45
+ given().when().get("/users/nope-" + shortId())
46
+ .then().statusCode(404);
47
+ }
48
+
49
+ private static String shortId()
50
+ {
51
+ return UUID.randomUUID().toString().substring(0, 8);
52
+ }
53
+
54
+ @Transactional
55
+ User persistUser(String name, String displayName)
56
+ {
57
+ User user = new User();
58
+ user.oidcSub = name;
59
+ user.username = name;
60
+ user.displayName = displayName;
61
+ user.persist();
62
+ return user;
63
+ }
64
+}
MODIFY
src/test/java/de/workaround/web/SearchUiTest.java
+15 -1
@@ -36,7 +36,21 @@
36
36
.then().statusCode(200)
37
37
.body(containsString("/repos/owner-" + tok + "/repo-" + tok))
38
38
.body(not(containsString("priv-" + tok)))
39
- .body(containsString("owner-" + tok));
39
+ .body(containsString("owner-" + tok))
40
+ // person hits link to the profile page (#11)
41
+ .body(containsString("/users/owner-" + tok));
42
+ }
43
+
44
+ @Test
45
+ void resultsPageHasNoOwnSearchInputAndPrefillsHeader()
46
+ {
47
+ String tok = "dup" + shortId();
48
+ given().when().get("/search?q=" + tok)
49
+ .then().statusCode(200)
50
+ // the page must not carry its own duplicate search form (class="search")
51
+ .body(not(containsString("class=\"search\"")))
52
+ // the single (header) search box shows what was searched
53
+ .body(containsString("value=\"" + tok + "\""));
40
54
}
41
55
42
56
@Test