✨ (orgs): Add organisations with guest/member/owner roles
Changes
68 files changed, +2094 -149
MODIFY
README.md
+9 -3
@@ -9,6 +9,11 @@
9
9
## Features
10
10
11
11
- Create, browse, and delete personal repositories (public or private)
12
+- **Organisations** — shared repository namespaces with guest/member/owner roles. An org owns
13
+ repositories exactly like a user (`/repos/<org>/<repo>`, same clone URL forms); org and user
14
+ names share one collision-checked handle namespace. Guests read private org repos, members
15
+ also push, owners manage members and org repositories. Guides:
16
+ [for users](docs/users/organisations.md), [for admins](docs/admins/organisations.md)
12
17
- Clone/fetch/push over `https://<host>/git/<owner>/<repo>.git`
13
18
- anonymous read on public repositories
14
19
- push and private read authenticate with **personal access tokens** (HTTP Basic password)
@@ -23,7 +28,7 @@
23
28
- The merge request detail page renders the live diff of the source branch relative to the merge base with the target (three-dot diff), file by file with per-line add/delete coloring and a changed-files / +additions / −deletions summary — always computed live from git, never duplicated into the database
24
29
- The owner or a collaborator can Merge or Close an open merge request from the detail page; merging runs entirely in-core against the bare repository (no working tree), fast-forwarding when possible or else recording a two-parent merge commit authored by the acting user and advancing the target branch ref. An automatic merge that would conflict is rejected; a source branch already contained in the target is treated as already merged
25
30
- Line-level review comments on a merge request's diff: any authenticated user who can read the repository can comment on a specific diff line (added, deleted, or context) from the merge-request detail page; comments render inline beneath the line they anchor to. A comment can be deleted by its author, the repository owner, or a collaborator. Comments are anchored to a file plus the diff line's old/new line numbers and must land on a line that's part of the current diff. Hovering a commentable line reveals a comment icon on the right; clicking it opens the form inline — a progressive-enhancement disclosure that works without JavaScript
26
-- OIDC login (authorization code flow) via `GET /login`; on first login the user account is created without a username and the browser is redirected to `/onboarding`, where the user picks a URL-safe handle (`^[a-z0-9][a-z0-9-]{0,38}$`, unique). The chosen handle — not the OIDC `preferred_username` claim (which is an SPN form in kanidm and not URL-safe) — is used in all repo, SSH, ActivityPub, and webfinger URLs. The `name` claim becomes an editable display name; both can be changed later at `/settings/profile`. A request filter blocks all app pages until a handle is chosen. Logout is local-session only via `POST /logout` (the kanidm provider advertises no `end_session_endpoint`, so RP-Initiated Logout is disabled)
31
+- OIDC login (authorization code flow) via `GET /login`; on first login the user account is created without a username and the browser is redirected to `/onboarding`, where the user picks a URL-safe handle (`^[a-z0-9][a-z0-9-]{0,38}$`, unique across users and organisations). The chosen handle — not the OIDC `preferred_username` claim (which is an SPN form in kanidm and not URL-safe) — is used in all repo, SSH, ActivityPub, and webfinger URLs. The `name` claim becomes an editable display name; both can be changed later at `/settings/profile`. A request filter blocks all app pages until a handle is chosen. Logout is local-session only via `POST /logout` (the kanidm provider advertises no `end_session_endpoint`, so RP-Initiated Logout is disabled)
27
32
- Profile pictures: users can upload a PNG/JPEG/GIF/WebP avatar (≤ 2 MB, content-type and magic bytes both validated) at `/settings/profile`, stored on the filesystem keyed by user UUID and served publicly at `GET /users/{username}/avatar`; shown wherever a local user is rendered (header nav, repo lists, repo sidebar, issue/MR/comment authors) via a reusable Qute avatar tag, removable, and falling back to an initials badge when absent. Git commit authors and remote federation actors are not local users and keep their existing pseudo-avatars
28
33
- **Collaborators** — the repository owner can grant other local users read+write access
29
34
(one flat role) on a per-repository settings page (`…/settings/collaborators`, linked in the
@@ -32,8 +37,9 @@
32
37
managing mirrors, and managing collaborators stay owner-only. Guides:
33
38
[for users](docs/users/collaborators.md), [for admins](docs/admins/collaborators.md)
34
39
- Per-repository images: the repo owner can upload a custom image (same PNG/JPEG/GIF/WebP, ≤ 2 MB, validated rules as avatars) on a dedicated owner-only repository **Settings** page (`/repos/{owner}/{name}/settings`), stored on the filesystem keyed by repo UUID. It replaces the owner's avatar wherever the repository is shown (repo lists, repo sidebar); a repository with no custom image falls back to its owner's avatar. Served at `GET /repos/{owner}/{name}/image`, visibility-guarded so a private repo's image never leaks (`404` for non-viewers), and removable back to the fallback
35
-- Single access policy on all paths: owner read/write, collaborators read/write, public
36
- world-readable, private repositories visible to the owner and collaborators only
40
+- Single access policy on all paths: owner read/write, collaborators read/write, org roles
41
+ (guest read / member write / owner admin) on org repositories, public world-readable,
42
+ private repositories visible only to whoever holds a read grant
37
43
- **JSON REST API** under `/api/v1`, authenticated with the same personal access tokens as
38
44
git-over-HTTP (`Authorization: Bearer <token>`), auto-documented via OpenAPI/Swagger UI (see below)
39
45
- **MCP server** at `/mcp` (Streamable HTTP), exposing the same feature set as the REST API as
MODIFY
docs/README.md
+6 -0
@@ -19,6 +19,9 @@
19
19
troubleshooting.
20
20
- **[Collaborators](users/collaborators.md)** — grant other users read and
21
21
write access to your repository, what collaborators can and cannot do.
22
+- **[Organisations](users/organisations.md)** — shared repository namespaces
23
+ with guest/member/owner roles: creating an org, managing members, org
24
+ repositories.
22
25
23
26
### For admins
24
27
@@ -34,6 +37,9 @@
34
37
SSRF behavior, the sync queue, and the tables involved.
35
38
- **[Collaborators](admins/collaborators.md)** — access semantics, endpoints,
36
39
and the `repository_collaborators` table (no configuration needed).
40
+- **[Organisations](admins/organisations.md)** — shared handle namespace, owner
41
+ resolution, role semantics, endpoints, and the `organisations` /
42
+ `organisation_members` tables (no configuration needed).
37
43
38
44
### For maintainers
39
45
ADD
docs/admins/organisations.md
+53 -0
@@ -0,0 +1,53 @@
1
+# Organisations (admin notes)
2
+
3
+Organisations need **no configuration** — the feature is always on and purely
4
+database-backed. This page documents the semantics and the tables involved.
5
+
6
+## Semantics
7
+
8
+- Organisation names live in the **same handle namespace as usernames**. Creating an
9
+ organisation checks `users.username` and `organisations.name`; choosing or renaming a
10
+ username checks `organisations.name` too. Each table also has its own unique index,
11
+ but the cross-table check is application-level and runs inside the creating
12
+ transaction.
13
+- A repository is owned by **exactly one** user or organisation
14
+ (`repositories.owner_user_id` XOR `repositories.owner_org_id`, DB CHECK
15
+ `repositories_exactly_one_owner`).
16
+- The `{owner}` segment of every repo route (`/repos/{owner}/{name}`, smart HTTP
17
+ `/git/{owner}/{name}.git`, SSH path) resolves to a user first, then an organisation.
18
+- Authorization flows through the same single access policy as everything else:
19
+ org **guest** = read (including private org repos), **member** = read+write,
20
+ **owner** = admin (repo delete/settings/collaborators/mirrors, member management,
21
+ org deletion). The last owner of an organisation cannot be removed or downgraded.
22
+- Deleting an organisation is blocked while it still owns repositories.
23
+- On-disk storage is unchanged: bare repositories live under
24
+ `<storage-root>/<owner-uuid>/<repo-uuid>.git`, where the owner UUID is the user's or
25
+ the organisation's id.
26
+
27
+## Endpoints
28
+
29
+| Route | Access |
30
+|---|---|
31
+| `GET /orgs/new`, `POST /orgs` | any logged-in user |
32
+| `GET /orgs/{name}` | public (repo list filtered by viewer visibility) |
33
+| `GET/POST /orgs/{name}/members`, `POST /orgs/{name}/members/{username}/role`, `POST /orgs/{name}/members/{username}/remove` | org owners only |
34
+| `POST /orgs/{name}/delete` | org owners only |
35
+
36
+## Tables
37
+
38
+Added by migration `V14__organisations.sql`:
39
+
40
+| Table | Purpose |
41
+|---|---|
42
+| `organisations` | id, unique `name` (shared handle namespace), optional `display_name`, `created_at` |
43
+| `organisation_members` | (organisation, user, role ∈ GUEST/MEMBER/OWNER), unique per (organisation, user), cascade-deleted with either side |
44
+
45
+The same migration makes `repositories.owner_id` nullable as `owner_user_id`, adds
46
+`owner_org_id`, a CHECK that exactly one is set, and a unique index on
47
+`(owner_org_id, name)`.
48
+
49
+## Federation
50
+
51
+Organisations have no ActivityPub actor and no WebFinger entry yet. Because org names
52
+are reserved in the shared handle namespace, enabling org actors later cannot collide
53
+with user actors. Nothing to configure or proxy for orgs today.
MODIFY
docs/maintainers/forgefed.md
+7 -0
@@ -193,6 +193,13 @@
193
193
offers, `Fork`, stars/watch semantics. Issues, MRs, and comments are
194
194
local-only today.
195
195
- **NodeInfo** endpoint and instance-level metadata for discovery.
196
+- **Organisation actors** — organisations (shared repo namespaces, see
197
+ `docs/users/organisations.md`) have no ActivityPub actor or WebFinger entry.
198
+ Their names are already reserved in the shared user/org handle namespace
199
+ (cross-table collision check on user onboarding and org creation), so a
200
+ later `Group`-style actor cannot collide with a `Person` actor. Repositories
201
+ owned by an org federate like any other repo actor; only the owning actor
202
+ itself is missing.
196
203
- **RFC 9421 signatures** as the ecosystem migrates (double-knocking).
197
204
198
205
Operational gaps:
ADD
docs/users/organisations.md
+64 -0
@@ -0,0 +1,64 @@
1
+# Organisations
2
+
3
+An organisation is a shared namespace for repositories, owned and used by a group of
4
+members instead of a single user. An organisation owns repositories exactly like a user
5
+does — an org repository lives at `/repos/<org>/<repo>` and is cloned via the same HTTP
6
+and SSH URL forms as a personal one.
7
+
8
+Organisation names share **one namespace with usernames**: you cannot create an
9
+organisation named like an existing user, and nobody can pick a username matching an
10
+existing organisation.
11
+
12
+## Creating an organisation
13
+
14
+1. On your dashboard (`/`), click **New organisation** (next to "New repository").
15
+2. Pick a **name** — same rules as usernames: 1–39 characters, lowercase letters,
16
+ digits or hyphens, starting with a letter or digit, unique across users *and*
17
+ organisations. Optionally add a display name.
18
+
19
+You become the organisation's first **owner**.
20
+
21
+## Member levels
22
+
23
+| Level | Rights |
24
+|---|---|
25
+| **guest** | Read-only: sees the org's private repositories in the UI and can clone/fetch them over HTTP and SSH, but cannot push |
26
+| **member** | Read + write: everything a guest can, plus push over HTTP and SSH |
27
+| **owner** | Admin: manage members and their roles, create and delete org repositories, manage their settings/collaborators/mirrors, delete the organisation |
28
+
29
+Public org repositories are world-readable like any public repository.
30
+
31
+## Managing members
32
+
33
+Open the organisation page at `/orgs/<name>` and click **Members** (owners only).
34
+
35
+- **Add**: enter the exact username of a local user and pick a role. Adding takes
36
+ effect immediately — there is no invitation to accept. The page tells you when the
37
+ username doesn't exist or the user is already a member.
38
+- **Change role**: pick a new role in the member's row.
39
+- **Remove**: click **Remove** next to the member; their access ends immediately.
40
+
41
+An organisation must always keep at least one owner: the last owner cannot be removed
42
+or downgraded.
43
+
44
+## Repositories in an organisation
45
+
46
+When creating a repository (**New repository**), an **Owner** selector appears if you
47
+own any organisations — choose yourself or one of them. Only organisation **owners**
48
+can create repositories in an org.
49
+
50
+Org repositories behave like personal ones everywhere else: issues, merge requests,
51
+collaborators, mirrors, pinning, custom repository image. Administrative actions on
52
+them (settings, delete, collaborators, mirrors) are available to every org owner.
53
+
54
+## Deleting an organisation
55
+
56
+On the Members page, under **Danger zone**, type the organisation name to confirm.
57
+An organisation that still owns repositories cannot be deleted — delete its
58
+repositories first.
59
+
60
+## Federation
61
+
62
+Organisations are not federated actors yet: an org has no ActivityPub identity and no
63
+WebFinger entry. The name is still reserved instance-wide, so org actor support can be
64
+added later without collisions.
ADD
src/main/java/de/workaround/account/HandleService.java
+45 -0
@@ -0,0 +1,45 @@
1
+package de.workaround.account;
2
+
3
+import java.util.Optional;
4
+import java.util.UUID;
5
+import java.util.regex.Pattern;
6
+
7
+import de.workaround.model.Organisation;
8
+import de.workaround.model.User;
9
+import jakarta.enterprise.context.ApplicationScoped;
10
+import jakarta.inject.Inject;
11
+
12
+/**
13
+ * The shared handle namespace: usernames and organisation names are both path segments in
14
+ * repo/SSH/federation URLs, so they follow one charset rule and must be unique across BOTH tables.
15
+ * Each table has its own unique index; the cross-table check here is authoritative and runs inside
16
+ * the caller's transaction ({@link UsernameService}, {@link OrganisationService}).
17
+ */
18
+@ApplicationScoped
19
+public class HandleService
20
+{
21
+ public static final Pattern HANDLE = Pattern.compile("^[a-z0-9][a-z0-9-]{0,38}$");
22
+
23
+ @Inject
24
+ User.Repo users;
25
+
26
+ @Inject
27
+ Organisation.Repo organisations;
28
+
29
+ public static boolean valid(String handle)
30
+ {
31
+ return handle != null && HANDLE.matcher(handle).matches();
32
+ }
33
+
34
+ /** True when another user or any organisation already holds the handle. */
35
+ public boolean takenByOther(String handle, UUID exceptUserId)
36
+ {
37
+ Optional<User> user = users.findByUsername(handle);
38
+ if (user.isPresent() && !user.get().id.equals(exceptUserId))
39
+ {
40
+ return true;
41
+ }
42
+ return organisations.findByName(handle).isPresent();
43
+ }
44
+
45
+}
ADD
src/main/java/de/workaround/account/InvalidOrganisationException.java
+10 -0
@@ -0,0 +1,10 @@
1
+package de.workaround.account;
2
+
3
+/** Rejected organisation input: invalid or taken name, unknown member, duplicate member, last-owner violations. */
4
+public class InvalidOrganisationException extends RuntimeException
5
+{
6
+ public InvalidOrganisationException(String message)
7
+ {
8
+ super(message);
9
+ }
10
+}
ADD
src/main/java/de/workaround/account/OrganisationService.java
+172 -0
@@ -0,0 +1,172 @@
1
+package de.workaround.account;
2
+
3
+import java.util.List;
4
+import java.util.Optional;
5
+
6
+import de.workaround.git.AccessPolicy;
7
+import de.workaround.git.ForbiddenOperationException;
8
+import de.workaround.model.Organisation;
9
+import de.workaround.model.OrganisationMember;
10
+import de.workaround.model.Repository;
11
+import de.workaround.model.User;
12
+import jakarta.enterprise.context.ApplicationScoped;
13
+import jakarta.inject.Inject;
14
+import jakarta.transaction.Transactional;
15
+
16
+/**
17
+ * Organisation lifecycle and membership. The creator becomes OWNER; only owners manage members and
18
+ * settings; an organisation always keeps at least one owner. Also serves as the
19
+ * {@link AccessPolicy}'s org-role lookup, so HTTP, SSH and UI authorization all see the same
20
+ * memberships.
21
+ */
22
+@ApplicationScoped
23
+public class OrganisationService implements AccessPolicy.OrganisationRoleLookup
24
+{
25
+ @Inject
26
+ Organisation.Repo organisations;
27
+
28
+ @Inject
29
+ OrganisationMember.Repo members;
30
+
31
+ @Inject
32
+ User.Repo users;
33
+
34
+ @Inject
35
+ Repository.Repo repositories;
36
+
37
+ @Inject
38
+ HandleService handles;
39
+
40
+ @Transactional
41
+ public Organisation create(User creator, String name, String displayName)
42
+ {
43
+ String candidate = name == null ? "" : name.trim();
44
+ if (!HandleService.valid(candidate))
45
+ {
46
+ throw new InvalidOrganisationException(
47
+ "Organisation names must be 1-39 characters, lowercase letters, digits or hyphens, and start with a letter or digit.");
48
+ }
49
+ if (handles.takenByOther(candidate, null))
50
+ {
51
+ throw new InvalidOrganisationException("That name is already taken.");
52
+ }
53
+ Organisation organisation = new Organisation();
54
+ organisation.name = candidate;
55
+ organisation.displayName = displayName == null || displayName.isBlank() ? null : displayName.trim();
56
+ organisation.persist();
57
+
58
+ OrganisationMember membership = new OrganisationMember();
59
+ membership.organisation = organisation;
60
+ membership.user = users.findById(creator.id);
61
+ membership.role = OrganisationMember.Role.OWNER;
62
+ membership.persist();
63
+ return organisation;
64
+ }
65
+
66
+ public Optional<Organisation> find(String name)
67
+ {
68
+ return organisations.findByName(name);
69
+ }
70
+
71
+ @Transactional
72
+ public OrganisationMember addMember(User actor, Organisation organisation, String username,
73
+ OrganisationMember.Role role)
74
+ {
75
+ requireOwner(actor, organisation);
76
+ User user = users.findByUsername(username == null ? "" : username.strip())
77
+ .orElseThrow(() -> new InvalidOrganisationException("No user with that username exists."));
78
+ if (members.findByOrganisationAndUser(organisation, user).isPresent())
79
+ {
80
+ throw new InvalidOrganisationException("That user is already a member.");
81
+ }
82
+ OrganisationMember membership = new OrganisationMember();
83
+ membership.organisation = organisation;
84
+ membership.user = user;
85
+ membership.role = role;
86
+ membership.persist();
87
+ return membership;
88
+ }
89
+
90
+ @Transactional
91
+ public void changeRole(User actor, Organisation organisation, String username, OrganisationMember.Role role)
92
+ {
93
+ requireOwner(actor, organisation);
94
+ OrganisationMember membership = requireMembership(organisation, username);
95
+ if (membership.role == OrganisationMember.Role.OWNER && role != OrganisationMember.Role.OWNER)
96
+ {
97
+ requireAnotherOwner(organisation);
98
+ }
99
+ membership.role = role;
100
+ }
101
+
102
+ @Transactional
103
+ public void removeMember(User actor, Organisation organisation, String username)
104
+ {
105
+ requireOwner(actor, organisation);
106
+ OrganisationMember membership = requireMembership(organisation, username);
107
+ if (membership.role == OrganisationMember.Role.OWNER)
108
+ {
109
+ requireAnotherOwner(organisation);
110
+ }
111
+ members.deleteById(membership.id);
112
+ }
113
+
114
+ @Transactional
115
+ public void delete(User actor, Organisation organisation)
116
+ {
117
+ requireOwner(actor, organisation);
118
+ if (repositories.countByOwnerOrg(organisation) > 0)
119
+ {
120
+ throw new InvalidOrganisationException(
121
+ "The organisation still owns repositories. Delete or transfer them first.");
122
+ }
123
+ // remove memberships through the ORM (not only the DB cascade) so entities already loaded in
124
+ // this session don't keep referencing the removed organisation at the next flush
125
+ members.findByOrganisation(organisation).forEach(membership -> members.deleteById(membership.id));
126
+ organisations.deleteById(organisation.id);
127
+ }
128
+
129
+ public List<OrganisationMember> members(Organisation organisation)
130
+ {
131
+ return members.findByOrganisation(organisation);
132
+ }
133
+
134
+ /** Organisations in which the user holds the OWNER role — the ones they may create repositories in. */
135
+ public List<Organisation> ownedBy(User user)
136
+ {
137
+ return members.findOwnerships(user).stream().map(membership -> membership.organisation).toList();
138
+ }
139
+
140
+ @Override
141
+ public Optional<OrganisationMember.Role> roleOf(User user, Organisation organisation)
142
+ {
143
+ return members.findByOrganisationAndUser(organisation, user).map(membership -> membership.role);
144
+ }
145
+
146
+ private OrganisationMember requireMembership(Organisation organisation, String username)
147
+ {
148
+ return users.findByUsername(username == null ? "" : username.strip())
149
+ .flatMap(user -> members.findByOrganisationAndUser(organisation, user))
150
+ .orElseThrow(() -> new InvalidOrganisationException("That user is not a member."));
151
+ }
152
+
153
+ private void requireAnotherOwner(Organisation organisation)
154
+ {
155
+ if (members.countOwners(organisation) <= 1)
156
+ {
157
+ throw new InvalidOrganisationException(
158
+ "Cannot remove or downgrade the last owner of an organisation.");
159
+ }
160
+ }
161
+
162
+ private void requireOwner(User actor, Organisation organisation)
163
+ {
164
+ boolean owner = actor != null && actor.id != null
165
+ && roleOf(actor, organisation).filter(role -> role == OrganisationMember.Role.OWNER).isPresent();
166
+ if (!owner)
167
+ {
168
+ throw new ForbiddenOperationException("Only an organisation owner can do that");
169
+ }
170
+ }
171
+
172
+}
MODIFY
src/main/java/de/workaround/account/UsernameService.java
+7 -9
@@ -1,8 +1,5 @@
1
1
package de.workaround.account;
2
2
3
-import java.util.Optional;
4
-import java.util.regex.Pattern;
5
-
6
3
import de.workaround.model.User;
7
4
import jakarta.enterprise.context.ApplicationScoped;
8
5
import jakarta.inject.Inject;
@@ -11,27 +8,28 @@
11
8
/**
12
9
* Validates and assigns a user's URL-safe handle. Used both during onboarding and when an existing
13
10
* user renames in settings. The handle is the path segment in every repo/SSH/federation URL, so it
14
- * must match a strict charset and be unique across users.
11
+ * must match a strict charset and be unique across users AND organisations (shared namespace, see
12
+ * {@link HandleService}).
15
13
*/
16
14
@ApplicationScoped
17
15
public class UsernameService
18
16
{
19
- public static final Pattern HANDLE = Pattern.compile("^[a-z0-9][a-z0-9-]{0,38}$");
20
-
21
17
@Inject
22
18
User.Repo users;
23
19
20
+ @Inject
21
+ HandleService handles;
22
+
24
23
@Transactional
25
24
public void choose(User user, String handle)
26
25
{
27
26
String candidate = handle == null ? "" : handle.trim();
28
- if (!HANDLE.matcher(candidate).matches())
27
+ if (!HandleService.valid(candidate))
29
28
{
30
29
throw new InvalidUsernameException(
31
30
"Username must be 1-39 characters, lowercase letters, digits or hyphens, and start with a letter or digit.");
32
31
}
33
- Optional<User> existing = users.findByUsername(candidate);
34
- if (existing.isPresent() && !existing.get().id.equals(user.id))
32
+ if (handles.takenByOther(candidate, user.id))
35
33
{
36
34
throw new UsernameTakenException("That username is already taken.");
37
35
}
MODIFY
src/main/java/de/workaround/api/ApiModels.java
+1 -1
@@ -25,7 +25,7 @@
25
25
{
26
26
public static RepositoryView of(Repository repo)
27
27
{
28
- return new RepositoryView(repo.owner.username, repo.name, repo.visibility, repo.description,
28
+ return new RepositoryView(repo.ownerHandle(), repo.name, repo.visibility, repo.description,
29
29
repo.createdAt);
30
30
}
31
31
}
MODIFY
src/main/java/de/workaround/federation/ActorDocuments.java
+1 -1
@@ -35,7 +35,7 @@
35
35
{
36
36
String id = uris.repository(repo);
37
37
ObjectNode node = actorBase(id, "Repository", true);
38
- node.put("name", repo.owner.username + "/" + repo.name);
38
+ node.put("name", repo.ownerHandle() + "/" + repo.name);
39
39
node.put("preferredUsername", repo.name);
40
40
if (repo.description != null)
41
41
{
MODIFY
src/main/java/de/workaround/federation/ActorUris.java
+1 -1
@@ -19,7 +19,7 @@
19
19
20
20
public String repository(Repository repo)
21
21
{
22
- return repository(repo.owner.username, repo.name);
22
+ return repository(repo.ownerHandle(), repo.name);
23
23
}
24
24
25
25
public String repository(String owner, String name)
MODIFY
src/main/java/de/workaround/git/AccessPolicy.java
+34 -9
@@ -1,40 +1,50 @@
1
1
package de.workaround.git;
2
2
3
+import java.util.Optional;
4
+
5
+import de.workaround.model.Organisation;
6
+import de.workaround.model.OrganisationMember;
3
7
import de.workaround.model.Repository;
4
8
import de.workaround.model.User;
5
9
import jakarta.enterprise.context.ApplicationScoped;
6
10
import jakarta.inject.Inject;
7
11
8
12
/**
9
- * Single authorization rule set shared by HTTP, SSH and UI: the owner has full access, collaborators
10
- * read and write repositories they were added to, public repositories are world-readable, private
11
- * repositories are visible to the owner and collaborators only.
13
+ * Single authorization rule set shared by HTTP, SSH and UI. Personal repositories: the owner has
14
+ * full access, collaborators read and write. Organisation repositories: GUESTs read, MEMBERs also
15
+ * write, OWNERs additionally administer. Public repositories are world-readable; private ones are
16
+ * visible only to whoever holds a read grant.
12
17
*/
13
18
@ApplicationScoped
14
19
public class AccessPolicy
15
20
{
16
21
private final CollaboratorLookup collaborators;
17
22
23
+ private final OrganisationRoleLookup orgRoles;
24
+
18
25
@Inject
19
- public AccessPolicy(CollaboratorLookup collaborators)
26
+ public AccessPolicy(CollaboratorLookup collaborators, OrganisationRoleLookup orgRoles)
20
27
{
21
28
this.collaborators = collaborators;
29
+ this.orgRoles = orgRoles;
22
30
}
23
31
24
32
public boolean canRead(User user, Repository repository)
25
33
{
26
- return repository.visibility == Repository.Visibility.PUBLIC || isOwner(user, repository)
27
- || isCollaborator(user, repository);
34
+ return repository.visibility == Repository.Visibility.PUBLIC || repository.isOwnedBy(user)
35
+ || isCollaborator(user, repository) || hasOrgRole(user, repository, OrganisationMember.Role.GUEST);
28
36
}
29
37
30
38
public boolean canWrite(User user, Repository repository)
31
39
{
32
- return isOwner(user, repository) || isCollaborator(user, repository);
40
+ return repository.isOwnedBy(user) || isCollaborator(user, repository)
41
+ || hasOrgRole(user, repository, OrganisationMember.Role.MEMBER);
33
42
}
34
43
35
- private static boolean isOwner(User user, Repository repository)
44
+ /** Administrative access (delete, settings, collaborator/mirror management): personal owner or org OWNER. */
45
+ public boolean canAdmin(User user, Repository repository)
36
46
{
37
- return user != null && user.id != null && user.id.equals(repository.owner.id);
47
+ return repository.isOwnedBy(user) || hasOrgRole(user, repository, OrganisationMember.Role.OWNER);
38
48
}
39
49
40
50
private boolean isCollaborator(User user, Repository repository)
@@ -42,10 +52,25 @@
42
52
return user != null && user.id != null && collaborators.isCollaborator(user, repository);
43
53
}
44
54
55
+ private boolean hasOrgRole(User user, Repository repository, OrganisationMember.Role atLeast)
56
+ {
57
+ return user != null && user.id != null && repository.ownerOrg != null
58
+ && orgRoles.roleOf(user, repository.ownerOrg).filter(role -> role.atLeast(atLeast)).isPresent();
59
+ }
60
+
45
61
/** How the policy asks whether a user holds a collaborator grant; implemented by {@link CollaboratorService}. */
46
62
public interface CollaboratorLookup
47
63
{
48
64
boolean isCollaborator(User user, Repository repository);
49
65
}
50
66
67
+ /**
68
+ * How the policy asks which role a user holds in an organisation; implemented by
69
+ * {@link de.workaround.account.OrganisationService}.
70
+ */
71
+ public interface OrganisationRoleLookup
72
+ {
73
+ Optional<OrganisationMember.Role> roleOf(User user, Organisation organisation);
74
+ }
75
+
51
76
}
MODIFY
src/main/java/de/workaround/git/CollaboratorService.java
+11 -3
@@ -23,13 +23,18 @@
23
23
@Inject
24
24
User.Repo users;
25
25
26
+ // The org-role lookup instead of AccessPolicy itself: the policy already injects THIS service
27
+ // as its collaborator lookup, so going through the policy would be a circular reference.
28
+ @Inject
29
+ AccessPolicy.OrganisationRoleLookup orgRoles;
30
+
26
31
@Transactional
27
32
public RepositoryCollaborator add(User actor, Repository repository, String username)
28
33
{
29
34
requireOwner(actor, repository);
30
35
User user = users.findByUsername(username == null ? "" : username.strip())
31
36
.orElseThrow(() -> new InvalidCollaboratorException("No user with that username exists."));
32
- if (user.id.equals(repository.owner.id))
37
+ if (repository.isOwnedBy(user))
33
38
{
34
39
throw new InvalidCollaboratorException("The owner cannot be added as a collaborator.");
35
40
}
@@ -64,9 +69,12 @@
64
69
return collaborators.findByRepositoryAndUser(repository, user).isPresent();
65
70
}
66
71
67
- private static void requireOwner(User actor, Repository repository)
72
+ private void requireOwner(User actor, Repository repository)
68
73
{
69
- if (actor == null || actor.id == null || !actor.id.equals(repository.owner.id))
74
+ boolean orgOwner = actor != null && actor.id != null && repository.ownerOrg != null
75
+ && orgRoles.roleOf(actor, repository.ownerOrg)
76
+ .filter(role -> role == de.workaround.model.OrganisationMember.Role.OWNER).isPresent();
77
+ if (!repository.isOwnedBy(actor) && !orgOwner)
70
78
{
71
79
throw new ForbiddenOperationException("Only the repository owner can manage collaborators");
72
80
}
MODIFY
src/main/java/de/workaround/git/GitRepositoryService.java
+46 -7
@@ -12,6 +12,7 @@
12
12
import org.eclipse.jgit.lib.Constants;
13
13
import org.eclipse.jgit.util.FileUtils;
14
14
15
+import de.workaround.model.Organisation;
15
16
import de.workaround.model.Repository;
16
17
import de.workaround.model.User;
17
18
import jakarta.enterprise.context.ApplicationScoped;
@@ -22,6 +23,8 @@
22
23
/**
23
24
* Owns the mapping between repository metadata (PostgreSQL) and bare repositories on disk.
24
25
* All transports (HTTP, SSH) and the UI resolve repositories exclusively through this service.
26
+ * The {@code {owner}} segment resolves to a user first, then an organisation — the shared handle
27
+ * namespace guarantees the order is only a lookup detail, never an ambiguity.
25
28
*/
26
29
@ApplicationScoped
27
30
public class GitRepositoryService
@@ -35,6 +38,12 @@
35
38
User.Repo users;
36
39
37
40
@Inject
41
+ Organisation.Repo organisations;
42
+
43
+ @Inject
44
+ AccessPolicy accessPolicy;
45
+
46
+ @Inject
38
47
RepositoryImageService images;
39
48
40
49
@ConfigProperty(name = "gitshark.storage.root")
@@ -44,14 +53,32 @@
44
53
public Repository create(User owner, String name, Repository.Visibility visibility, String description)
45
54
{
46
55
validateName(name);
47
- if (repositories.findByOwnerAndName(owner, name).isPresent())
56
+ if (repositories.findByOwnerUserAndName(owner, name).isPresent())
48
57
{
49
58
throw new RepositoryAlreadyExistsException(owner.username, name);
50
59
}
51
-
52
60
Repository repository = new Repository();
61
+ repository.ownerUser = owner;
62
+ return initialize(repository, name, visibility, description);
63
+ }
64
+
65
+ @Transactional
66
+ public Repository create(Organisation owner, String name, Repository.Visibility visibility, String description)
67
+ {
68
+ validateName(name);
69
+ if (repositories.findByOwnerOrgAndName(owner, name).isPresent())
70
+ {
71
+ throw new RepositoryAlreadyExistsException(owner.name, name);
72
+ }
73
+ Repository repository = new Repository();
74
+ repository.ownerOrg = owner;
75
+ return initialize(repository, name, visibility, description);
76
+ }
77
+
78
+ private Repository initialize(Repository repository, String name, Repository.Visibility visibility,
79
+ String description)
80
+ {
53
81
repository.name = name;
54
- repository.owner = owner;
55
82
repository.visibility = visibility;
56
83
repository.description = description;
57
84
repository.persist();
@@ -70,19 +97,26 @@
70
97
71
98
public Optional<Repository> find(String ownerName, String repositoryName)
72
99
{
73
- return users.findByUsername(ownerName)
74
- .flatMap(owner -> repositories.findByOwnerAndName(owner, stripDotGit(repositoryName)));
100
+ String name = stripDotGit(repositoryName);
101
+ Optional<Repository> byUser = users.findByUsername(ownerName)
102
+ .flatMap(owner -> repositories.findByOwnerUserAndName(owner, name));
103
+ if (byUser.isPresent())
104
+ {
105
+ return byUser;
106
+ }
107
+ return organisations.findByName(ownerName)
108
+ .flatMap(owner -> repositories.findByOwnerOrgAndName(owner, name));
75
109
}
76
110
77
111
public Path repositoryPath(Repository repository)
78
112
{
79
- return storageRoot.resolve(repository.owner.id.toString()).resolve(repository.id.toString() + ".git");
113
+ return storageRoot.resolve(repository.ownerId().toString()).resolve(repository.id.toString() + ".git");
80
114
}
81
115
82
116
@Transactional
83
117
public void delete(User actor, Repository repository)
84
118
{
85
- if (actor == null || actor.id == null || !actor.id.equals(repository.owner.id))
119
+ if (!accessPolicy.canAdmin(actor, repository))
86
120
{
87
121
throw new ForbiddenOperationException("Only the owner may delete a repository");
88
122
}
@@ -104,6 +138,11 @@
104
138
return user == null ? repositories.findPublic() : repositories.findVisibleTo(user);
105
139
}
106
140
141
+ public List<Repository> listOwnedBy(Organisation organisation)
142
+ {
143
+ return repositories.findByOwnerOrg(organisation);
144
+ }
145
+
107
146
private static void validateName(String name)
108
147
{
109
148
if (name == null || name.isEmpty() || name.equals(".") || name.equals("..")
MODIFY
src/main/java/de/workaround/http/GitHttpServlet.java
+1 -1
@@ -109,7 +109,7 @@
109
109
ReceivePack receivePack = new ReceivePack(db);
110
110
receivePack.setRefLogIdent(new org.eclipse.jgit.lib.PersonIdent(user.username, user.email != null ? user.email : user.username + "@git-shark"));
111
111
// Capture identifiers while the request session is open; the hook fires after receive-pack.
112
- String ownerName = repository.owner.username;
112
+ String ownerName = repository.ownerHandle();
113
113
String repoName = repository.name;
114
114
java.util.UUID pusherId = user.id;
115
115
receivePack.setPostReceiveHook((rp, commands) -> {
MODIFY
src/main/java/de/workaround/mirror/MirrorService.java
+6 -2
@@ -11,6 +11,7 @@
11
11
import org.eclipse.jgit.transport.ReceiveCommand;
12
12
13
13
import de.workaround.federation.FederationConfig;
14
+import de.workaround.git.AccessPolicy;
14
15
import de.workaround.git.ForbiddenOperationException;
15
16
import de.workaround.git.GitRepositoryService;
16
17
import de.workaround.model.MirrorSync;
@@ -47,6 +48,9 @@
47
48
GitRepositoryService repositories;
48
49
49
50
@Inject
51
+ AccessPolicy accessPolicy;
52
+
53
+ @Inject
50
54
MirrorPusher pusher;
51
55
52
56
@Inject
@@ -264,9 +268,9 @@
264
268
return mirror;
265
269
}
266
270
267
- private static void requireOwner(User actor, Repository repository)
271
+ private void requireOwner(User actor, Repository repository)
268
272
{
269
- if (actor == null || actor.id == null || !actor.id.equals(repository.owner.id))
273
+ if (!accessPolicy.canAdmin(actor, repository))
270
274
{
271
275
throw new ForbiddenOperationException("Only the repository owner may manage mirrors");
272
276
}
ADD
src/main/java/de/workaround/model/Organisation.java
+42 -0
@@ -0,0 +1,42 @@
1
+package de.workaround.model;
2
+
3
+import java.time.Instant;
4
+import java.util.Optional;
5
+import java.util.UUID;
6
+
7
+import org.hibernate.annotations.processing.Find;
8
+
9
+import io.quarkus.hibernate.panache.PanacheEntity;
10
+import io.quarkus.hibernate.panache.PanacheRepository;
11
+import jakarta.persistence.Entity;
12
+import jakarta.persistence.GeneratedValue;
13
+import jakarta.persistence.GenerationType;
14
+import jakarta.persistence.Id;
15
+import jakarta.persistence.Table;
16
+
17
+/**
18
+ * A shared repository namespace owned by its members. The name lives in the same handle namespace
19
+ * as {@link User#username} (one URL scheme for user and org repos), so creation collision-checks
20
+ * both tables via the handle service.
21
+ */
22
+@Entity
23
+@Table(name = "organisations")
24
+public class Organisation implements PanacheEntity.Managed
25
+{
26
+ @Id
27
+ @GeneratedValue(strategy = GenerationType.UUID)
28
+ public UUID id;
29
+
30
+ public String name;
31
+
32
+ public String displayName;
33
+
34
+ public Instant createdAt = Instant.now();
35
+
36
+ public interface Repo extends PanacheRepository.Managed<Organisation, UUID>
37
+ {
38
+ @Find
39
+ Optional<Organisation> findByName(String name);
40
+ }
41
+
42
+}
ADD
src/main/java/de/workaround/model/OrganisationMember.java
+77 -0
@@ -0,0 +1,77 @@
1
+package de.workaround.model;
2
+
3
+import java.time.Instant;
4
+import java.util.List;
5
+import java.util.Optional;
6
+import java.util.UUID;
7
+
8
+import org.hibernate.annotations.processing.Find;
9
+import org.hibernate.annotations.processing.HQL;
10
+
11
+import io.quarkus.hibernate.panache.PanacheEntity;
12
+import io.quarkus.hibernate.panache.PanacheRepository;
13
+import jakarta.persistence.Entity;
14
+import jakarta.persistence.EnumType;
15
+import jakarta.persistence.Enumerated;
16
+import jakarta.persistence.GeneratedValue;
17
+import jakarta.persistence.GenerationType;
18
+import jakarta.persistence.Id;
19
+import jakarta.persistence.ManyToOne;
20
+import jakarta.persistence.Table;
21
+import jakarta.persistence.UniqueConstraint;
22
+
23
+/**
24
+ * A user's membership in an organisation with one of three levels: GUEST reads private org
25
+ * repositories, MEMBER also pushes, OWNER additionally administers members, org settings and org
26
+ * repositories. Uniqueness is enforced per (organisation, user); an organisation always keeps at
27
+ * least one owner.
28
+ */
29
+@Entity
30
+@Table(name = "organisation_members",
31
+ uniqueConstraints = @UniqueConstraint(columnNames = { "organisation_id", "user_id" }))
32
+public class OrganisationMember implements PanacheEntity.Managed
33
+{
34
+ @Id
35
+ @GeneratedValue(strategy = GenerationType.UUID)
36
+ public UUID id;
37
+
38
+ @ManyToOne(optional = false)
39
+ public Organisation organisation;
40
+
41
+ @ManyToOne(optional = false)
42
+ public User user;
43
+
44
+ @Enumerated(EnumType.STRING)
45
+ public Role role;
46
+
47
+ public Instant createdAt = Instant.now();
48
+
49
+ /** Ordered by increasing privilege so {@code compareTo} expresses "at least". */
50
+ public enum Role
51
+ {
52
+ GUEST,
53
+ MEMBER,
54
+ OWNER;
55
+
56
+ public boolean atLeast(Role other)
57
+ {
58
+ return compareTo(other) >= 0;
59
+ }
60
+ }
61
+
62
+ public interface Repo extends PanacheRepository.Managed<OrganisationMember, UUID>
63
+ {
64
+ @Find
65
+ Optional<OrganisationMember> findByOrganisationAndUser(Organisation organisation, User user);
66
+
67
+ @HQL("where organisation = :organisation order by user.username")
68
+ List<OrganisationMember> findByOrganisation(Organisation organisation);
69
+
70
+ @HQL("where user = :user and role = OWNER order by organisation.name")
71
+ List<OrganisationMember> findOwnerships(User user);
72
+
73
+ @HQL("select count(m) from OrganisationMember m where m.organisation = :organisation and m.role = OWNER")
74
+ long countOwners(Organisation organisation);
75
+ }
76
+
77
+}
MODIFY
src/main/java/de/workaround/model/Repository.java
+38 -5
@@ -29,8 +29,13 @@
29
29
30
30
public String name;
31
31
32
- @ManyToOne(optional = false)
33
- public User owner;
32
+ // Exactly one of ownerUser/ownerOrg is set (DB CHECK repositories_exactly_one_owner). Both
33
+ // owner kinds share one handle namespace, so {owner}/{name} URLs stay unambiguous.
34
+ @ManyToOne
35
+ public User ownerUser;
36
+
37
+ @ManyToOne
38
+ public Organisation ownerOrg;
34
39
35
40
@Enumerated(EnumType.STRING)
36
41
public Visibility visibility;
@@ -50,6 +55,23 @@
50
55
return imageContentType != null;
51
56
}
52
57
58
+ /** The URL path segment of whoever owns this repository: the user's handle or the org's name. */
59
+ public String ownerHandle()
60
+ {
61
+ return ownerUser != null ? ownerUser.username : ownerOrg.name;
62
+ }
63
+
64
+ /** The owning user's or organisation's id — keys the on-disk storage directory. */
65
+ public UUID ownerId()
66
+ {
67
+ return ownerUser != null ? ownerUser.id : ownerOrg.id;
68
+ }
69
+
70
+ public boolean isOwnedBy(User user)
71
+ {
72
+ return user != null && user.id != null && ownerUser != null && user.id.equals(ownerUser.id);
73
+ }
74
+
53
75
public enum Visibility
54
76
{
55
77
PUBLIC,
@@ -59,10 +81,21 @@
59
81
public interface Repo extends PanacheRepository.Managed<Repository, UUID>
60
82
{
61
83
@Find
62
- Optional<Repository> findByOwnerAndName(User owner, String name);
84
+ Optional<Repository> findByOwnerUserAndName(User ownerUser, String name);
63
85
64
- @HQL("where owner = :owner or visibility = PUBLIC order by name")
65
- List<Repository> findVisibleTo(User owner);
86
+ @Find
87
+ Optional<Repository> findByOwnerOrgAndName(Organisation ownerOrg, String name);
88
+
89
+ @HQL("where ownerOrg = :ownerOrg order by name")
90
+ List<Repository> findByOwnerOrg(Organisation ownerOrg);
91
+
92
+ @HQL("select count(r) from Repository r where r.ownerOrg = :ownerOrg")
93
+ long countByOwnerOrg(Organisation ownerOrg);
94
+
95
+ @HQL("where ownerUser = :user or visibility = PUBLIC"
96
+ + " or ownerOrg.id in (select m.organisation.id from OrganisationMember m where m.user = :user)"
97
+ + " order by name")
98
+ List<Repository> findVisibleTo(User user);
66
99
67
100
@HQL("where visibility = PUBLIC order by name")
68
101
List<Repository> findPublic();
MODIFY
src/main/java/de/workaround/web/CollaboratorResource.java
+1 -1
@@ -108,7 +108,7 @@
108
108
// hide existence of private repositories
109
109
throw new NotFoundException();
110
110
}
111
- if (user == null || !user.id.equals(repo.owner.id))
111
+ if (!accessPolicy.canAdmin(user, repo))
112
112
{
113
113
throw new ForbiddenOperationException("Only the repository owner can manage collaborators");
114
114
}
MODIFY
src/main/java/de/workaround/web/HomeResource.java
+37 -7
@@ -7,10 +7,14 @@
7
7
import java.util.stream.Collectors;
8
8
9
9
import de.workaround.account.CurrentUser;
10
+import de.workaround.account.OrganisationService;
11
+import de.workaround.git.ForbiddenOperationException;
10
12
import de.workaround.git.GitRepositoryService;
11
13
import de.workaround.git.InvalidRepositoryNameException;
12
14
import de.workaround.git.RepositoryAlreadyExistsException;
13
15
import de.workaround.git.RepositoryPinService;
16
+import de.workaround.model.Organisation;
17
+import de.workaround.model.OrganisationMember;
14
18
import de.workaround.model.Repository;
15
19
import de.workaround.model.User;
16
20
import de.workaround.notify.NotificationItem;
@@ -41,7 +45,7 @@
41
45
42
46
static native TemplateInstance landing();
43
47
44
- static native TemplateInstance newRepo(String error);
48
+ static native TemplateInstance newRepo(String error, List<Organisation> orgs);
45
49
}
46
50
47
51
/** A repository row in the dashboard's full list, carrying whether the current user has pinned it. */
@@ -61,6 +65,9 @@
61
65
@Inject
62
66
NotificationService notifications;
63
67
68
+ @Inject
69
+ OrganisationService organisations;
70
+
64
71
@GET
65
72
public TemplateInstance home()
66
73
{
@@ -99,30 +106,53 @@
99
106
@Path("repos/new")
100
107
public TemplateInstance newRepo()
101
108
{
102
- currentUser.require();
103
- return Templates.newRepo(null);
109
+ User user = currentUser.require();
110
+ return Templates.newRepo(null, organisations.ownedBy(user));
104
111
}
105
112
106
113
@POST
107
114
@Path("repos")
108
115
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
109
116
public Response create(@FormParam("name") String name, @FormParam("visibility") String visibility,
110
- @FormParam("description") String description)
117
+ @FormParam("description") String description, @FormParam("owner") String ownerHandle)
111
118
{
112
119
User user = currentUser.require();
113
120
try
114
121
{
115
122
Repository.Visibility parsed = "PRIVATE".equalsIgnoreCase(visibility)
116
123
? Repository.Visibility.PRIVATE : Repository.Visibility.PUBLIC;
117
- service.create(user, name, parsed, description == null || description.isBlank() ? null : description);
118
- return Response.seeOther(URI.create("/repos/" + user.username + "/" + name)).build();
124
+ String trimmedDescription = description == null || description.isBlank() ? null : description;
125
+ Repository repo;
126
+ if (ownerHandle == null || ownerHandle.isBlank() || ownerHandle.equals(user.username))
127
+ {
128
+ repo = service.create(user, name, parsed, trimmedDescription);
129
+ }
130
+ else
131
+ {
132
+ repo = createInOrganisation(user, ownerHandle, name, parsed, trimmedDescription);
133
+ }
134
+ return Response.seeOther(URI.create("/repos/" + repo.ownerHandle() + "/" + repo.name)).build();
119
135
}
120
136
catch (InvalidRepositoryNameException | RepositoryAlreadyExistsException e)
121
137
{
122
138
return Response.status(Response.Status.BAD_REQUEST)
123
- .entity(Templates.newRepo(e.getMessage()))
139
+ .entity(Templates.newRepo(e.getMessage(), organisations.ownedBy(user)))
124
140
.build();
125
141
}
126
142
}
127
143
144
+ private Repository createInOrganisation(User user, String ownerHandle, String name,
145
+ Repository.Visibility visibility, String description)
146
+ {
147
+ Organisation org = organisations.find(ownerHandle)
148
+ .orElseThrow(() -> new ForbiddenOperationException("No such organisation"));
149
+ boolean orgOwner = organisations.roleOf(user, org)
150
+ .filter(role -> role == OrganisationMember.Role.OWNER).isPresent();
151
+ if (!orgOwner)
152
+ {
153
+ throw new ForbiddenOperationException("Only organisation owners can create repositories there");
154
+ }
155
+ return service.create(org, name, visibility, description);
156
+ }
157
+
128
158
}
MODIFY
src/main/java/de/workaround/web/IssueResource.java
+4 -4
@@ -68,7 +68,7 @@
68
68
{
69
69
Repository repo = requireReadable(owner, name);
70
70
User user = currentUser.get();
71
- boolean isOwner = user != null && user.id.equals(repo.owner.id);
71
+ boolean isOwner = accessPolicy.canAdmin(user, repo);
72
72
List<Issue> all = issueService.list(repo);
73
73
// open issues stay visible; DONE issues are tucked into a collapsible archive on the page
74
74
List<Issue> open = all.stream().filter(issue -> issue.status != Issue.Status.DONE).toList();
@@ -108,7 +108,7 @@
108
108
Repository repo = requireReadable(owner, name);
109
109
Issue issue = issueService.find(repo, number).orElseThrow(NotFoundException::new);
110
110
User user = currentUser.get();
111
- boolean isOwner = user != null && user.id.equals(repo.owner.id);
111
+ boolean isOwner = accessPolicy.canAdmin(user, repo);
112
112
String descriptionHtml = issue.description == null ? null : Markdown.render(issue.description);
113
113
return Templates.issue(repo, repoNav.build(repo, uriInfo), isOwner, issue, descriptionHtml,
114
114
List.of(Issue.Status.values()));
@@ -175,12 +175,12 @@
175
175
Repository repo = requireReadable(owner, name);
176
176
Issue issue = issueService.find(repo, number).orElseThrow(NotFoundException::new);
177
177
issueService.delete(currentUser.require(), issue);
178
- return Response.seeOther(URI.create("/repos/" + repo.owner.username + "/" + repo.name + "/issues")).build();
178
+ return Response.seeOther(URI.create("/repos/" + repo.ownerHandle() + "/" + repo.name + "/issues")).build();
179
179
}
180
180
181
181
private URI issueUri(Repository repo, int number)
182
182
{
183
- return URI.create("/repos/" + repo.owner.username + "/" + repo.name + "/issues/" + number);
183
+ return URI.create("/repos/" + repo.ownerHandle() + "/" + repo.name + "/issues/" + number);
184
184
}
185
185
186
186
private static Issue.Status parseStatus(String status)
MODIFY
src/main/java/de/workaround/web/MergeRequestResource.java
+2 -2
@@ -237,13 +237,13 @@
237
237
238
238
private URI detailUri(Repository repo, UUID id)
239
239
{
240
- return URI.create("/repos/" + repo.owner.username + "/" + repo.name + "/merge-requests/" + id);
240
+ return URI.create("/repos/" + repo.ownerHandle() + "/" + repo.name + "/merge-requests/" + id);
241
241
}
242
242
243
243
private boolean isOwner(Repository repo)
244
244
{
245
245
User user = currentUser.get();
246
- return user != null && user.id.equals(repo.owner.id);
246
+ return accessPolicy.canAdmin(user, repo);
247
247
}
248
248
249
249
private static UUID parseId(String id)
ADD
src/main/java/de/workaround/web/OrganisationResource.java
+223 -0
@@ -0,0 +1,223 @@
1
+package de.workaround.web;
2
+
3
+import java.net.URI;
4
+import java.util.List;
5
+
6
+import de.workaround.account.CurrentUser;
7
+import de.workaround.account.InvalidOrganisationException;
8
+import de.workaround.account.OrganisationService;
9
+import de.workaround.git.AccessPolicy;
10
+import de.workaround.git.ForbiddenOperationException;
11
+import de.workaround.git.GitRepositoryService;
12
+import de.workaround.model.Organisation;
13
+import de.workaround.model.OrganisationMember;
14
+import de.workaround.model.Repository;
15
+import de.workaround.model.User;
16
+import io.quarkus.qute.CheckedTemplate;
17
+import io.quarkus.qute.TemplateInstance;
18
+import jakarta.inject.Inject;
19
+import jakarta.ws.rs.Consumes;
20
+import jakarta.ws.rs.FormParam;
21
+import jakarta.ws.rs.GET;
22
+import jakarta.ws.rs.NotFoundException;
23
+import jakarta.ws.rs.POST;
24
+import jakarta.ws.rs.Path;
25
+import jakarta.ws.rs.PathParam;
26
+import jakarta.ws.rs.Produces;
27
+import jakarta.ws.rs.core.MediaType;
28
+import jakarta.ws.rs.core.Response;
29
+
30
+/**
31
+ * Organisation pages: creation (any logged-in user), the public org page with a
32
+ * visibility-filtered repository list, and owner-only member management. Org repositories
33
+ * themselves live under the same /repos/{owner}/{name} routes as personal ones.
34
+ */
35
+@Path("/orgs")
36
+@Produces(MediaType.TEXT_HTML)
37
+public class OrganisationResource
38
+{
39
+ @CheckedTemplate
40
+ static class Templates
41
+ {
42
+ static native TemplateInstance newOrg(String error);
43
+
44
+ static native TemplateInstance org(Organisation org, List<Repository> repos, boolean owner);
45
+
46
+ static native TemplateInstance members(Organisation org, List<OrganisationMember> members,
47
+ List<OrganisationMember.Role> roles, String error);
48
+ }
49
+
50
+ @Inject
51
+ CurrentUser currentUser;
52
+
53
+ @Inject
54
+ OrganisationService organisations;
55
+
56
+ @Inject
57
+ GitRepositoryService repositories;
58
+
59
+ @Inject
60
+ AccessPolicy accessPolicy;
61
+
62
+ @GET
63
+ @Path("new")
64
+ public TemplateInstance newOrg()
65
+ {
66
+ currentUser.require();
67
+ return Templates.newOrg(null);
68
+ }
69
+
70
+ @POST
71
+ @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
72
+ public Response create(@FormParam("name") String name, @FormParam("displayName") String displayName)
73
+ {
74
+ User user = currentUser.require();
75
+ try
76
+ {
77
+ Organisation org = organisations.create(user, name, displayName);
78
+ return Response.seeOther(URI.create("/orgs/" + org.name)).build();
79
+ }
80
+ catch (InvalidOrganisationException e)
81
+ {
82
+ return Response.status(Response.Status.BAD_REQUEST).entity(Templates.newOrg(e.getMessage())).build();
83
+ }
84
+ }
85
+
86
+ @GET
87
+ @Path("{name}")
88
+ public TemplateInstance page(@PathParam("name") String name)
89
+ {
90
+ Organisation org = requireOrg(name);
91
+ User user = currentUser.get();
92
+ List<Repository> visible = repositories.listOwnedBy(org).stream()
93
+ .filter(repo -> accessPolicy.canRead(user, repo))
94
+ .toList();
95
+ return Templates.org(org, visible, isOwner(user, org));
96
+ }
97
+
98
+ @GET
99
+ @Path("{name}/members")
100
+ public TemplateInstance members(@PathParam("name") String name)
101
+ {
102
+ Organisation org = requireOwned(name);
103
+ return membersPage(org, null);
104
+ }
105
+
106
+ @POST
107
+ @Path("{name}/members")
108
+ @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
109
+ public Response addMember(@PathParam("name") String name, @FormParam("username") String username,
110
+ @FormParam("role") String role)
111
+ {
112
+ Organisation org = requireOwned(name);
113
+ try
114
+ {
115
+ organisations.addMember(currentUser.require(), org, username, parseRole(role));
116
+ return backToMembers(org);
117
+ }
118
+ catch (InvalidOrganisationException e)
119
+ {
120
+ return Response.status(Response.Status.BAD_REQUEST).entity(membersPage(org, e.getMessage())).build();
121
+ }
122
+ }
123
+
124
+ @POST
125
+ @Path("{name}/members/{username}/role")
126
+ @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
127
+ public Response changeRole(@PathParam("name") String name, @PathParam("username") String username,
128
+ @FormParam("role") String role)
129
+ {
130
+ Organisation org = requireOwned(name);
131
+ try
132
+ {
133
+ organisations.changeRole(currentUser.require(), org, username, parseRole(role));
134
+ return backToMembers(org);
135
+ }
136
+ catch (InvalidOrganisationException e)
137
+ {
138
+ return Response.status(Response.Status.BAD_REQUEST).entity(membersPage(org, e.getMessage())).build();
139
+ }
140
+ }
141
+
142
+ @POST
143
+ @Path("{name}/members/{username}/remove")
144
+ public Response removeMember(@PathParam("name") String name, @PathParam("username") String username)
145
+ {
146
+ Organisation org = requireOwned(name);
147
+ try
148
+ {
149
+ organisations.removeMember(currentUser.require(), org, username);
150
+ return backToMembers(org);
151
+ }
152
+ catch (InvalidOrganisationException e)
153
+ {
154
+ return Response.status(Response.Status.BAD_REQUEST).entity(membersPage(org, e.getMessage())).build();
155
+ }
156
+ }
157
+
158
+ @POST
159
+ @Path("{name}/delete")
160
+ @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
161
+ public Response delete(@PathParam("name") String name, @FormParam("confirm") String confirm)
162
+ {
163
+ Organisation org = requireOwned(name);
164
+ if (!org.name.equals(confirm))
165
+ {
166
+ return Response.status(Response.Status.BAD_REQUEST)
167
+ .entity("Confirmation does not match the organisation name").build();
168
+ }
169
+ try
170
+ {
171
+ organisations.delete(currentUser.require(), org);
172
+ return Response.seeOther(URI.create("/")).build();
173
+ }
174
+ catch (InvalidOrganisationException e)
175
+ {
176
+ return Response.status(Response.Status.BAD_REQUEST).entity(membersPage(org, e.getMessage())).build();
177
+ }
178
+ }
179
+
180
+ private TemplateInstance membersPage(Organisation org, String error)
181
+ {
182
+ return Templates.members(org, organisations.members(org), List.of(OrganisationMember.Role.values()), error);
183
+ }
184
+
185
+ private static Response backToMembers(Organisation org)
186
+ {
187
+ return Response.seeOther(URI.create("/orgs/" + org.name + "/members")).build();
188
+ }
189
+
190
+ private static OrganisationMember.Role parseRole(String role)
191
+ {
192
+ try
193
+ {
194
+ return OrganisationMember.Role.valueOf(role == null ? "" : role.trim().toUpperCase());
195
+ }
196
+ catch (IllegalArgumentException e)
197
+ {
198
+ throw new InvalidOrganisationException("Unknown role.");
199
+ }
200
+ }
201
+
202
+ private Organisation requireOrg(String name)
203
+ {
204
+ return organisations.find(name).orElseThrow(NotFoundException::new);
205
+ }
206
+
207
+ private Organisation requireOwned(String name)
208
+ {
209
+ Organisation org = requireOrg(name);
210
+ if (!isOwner(currentUser.get(), org))
211
+ {
212
+ throw new ForbiddenOperationException("Only an organisation owner can do that");
213
+ }
214
+ return org;
215
+ }
216
+
217
+ private boolean isOwner(User user, Organisation org)
218
+ {
219
+ return user != null && user.id != null && organisations.roleOf(user, org)
220
+ .filter(role -> role == OrganisationMember.Role.OWNER).isPresent();
221
+ }
222
+
223
+}
MODIFY
src/main/java/de/workaround/web/RepoNavService.java
+7 -3
@@ -3,6 +3,7 @@
3
3
import java.nio.file.Path;
4
4
5
5
import de.workaround.account.CurrentUser;
6
+import de.workaround.git.AccessPolicy;
6
7
import de.workaround.git.GitBrowseService;
7
8
import de.workaround.git.GitRepositoryService;
8
9
import de.workaround.git.IssueService;
@@ -30,6 +31,9 @@
30
31
GitRepositoryService service;
31
32
32
33
@Inject
34
+ AccessPolicy accessPolicy;
35
+
36
+ @Inject
33
37
GitBrowseService browse;
34
38
35
39
@Inject
@@ -51,16 +55,16 @@
51
55
String defaultBranch = empty ? null : browse.defaultBranch(path);
52
56
User user = currentUser.get();
53
57
boolean loggedIn = user != null;
54
- boolean isOwner = loggedIn && user.id.equals(repo.owner.id);
58
+ boolean isOwner = loggedIn && accessPolicy.canAdmin(user, repo);
55
59
boolean pinned = loggedIn && pinService.isPinned(user, repo);
56
60
int commitCount = empty ? 0 : browse.commitCount(path, defaultBranch);
57
61
int branchCount = browse.branches(path).size();
58
62
int tagCount = browse.tags(path).size();
59
63
long openIssueCount = issueService.countOpen(repo);
60
64
long openMrCount = mergeRequestService.countOpen(repo);
61
- String httpUrl = uriInfo.getBaseUri().resolve("/git/" + repo.owner.username + "/" + repo.name + ".git")
65
+ String httpUrl = uriInfo.getBaseUri().resolve("/git/" + repo.ownerHandle() + "/" + repo.name + ".git")
62
66
.toString();
63
- String sshUrl = "ssh://git@" + uriInfo.getBaseUri().getHost() + ":" + sshPort + "/" + repo.owner.username
67
+ String sshUrl = "ssh://git@" + uriInfo.getBaseUri().getHost() + ":" + sshPort + "/" + repo.ownerHandle()
64
68
+ "/" + repo.name + ".git";
65
69
return new RepoNav(repo, loggedIn, isOwner, pinned, empty, defaultBranch, commitCount, branchCount, tagCount,
66
70
openIssueCount, openMrCount, httpUrl, sshUrl, uriInfo.getRequestUri().getRawPath());
MODIFY
src/main/java/de/workaround/web/RepositoryResource.java
+4 -5
@@ -111,7 +111,7 @@
111
111
? List.of()
112
112
: browse.listTree(path, nav.defaultBranch(), "").orElse(List.of());
113
113
User user = currentUser.get();
114
- boolean isOwner = user != null && user.id.equals(repo.owner.id);
114
+ boolean isOwner = accessPolicy.canAdmin(user, repo);
115
115
GitBrowseService.CommitInfo latestCommit = nav.empty() ? null
116
116
: browse.commits(path, nav.defaultBranch(), 0, 1)
117
117
.filter(commitPage -> !commitPage.commits().isEmpty())
@@ -325,7 +325,7 @@
325
325
326
326
private static URI settingsUri(Repository repo)
327
327
{
328
- return URI.create("/repos/" + repo.owner.username + "/" + repo.name + "/settings");
328
+ return URI.create("/repos/" + repo.ownerHandle() + "/" + repo.name + "/settings");
329
329
}
330
330
331
331
@POST
@@ -465,7 +465,7 @@
465
465
*/
466
466
private static List<Crumb> breadcrumbs(Repository repo, String ref, String path)
467
467
{
468
- String treeBase = "/repos/" + repo.owner.username + "/" + repo.name + "/tree/" + ref;
468
+ String treeBase = "/repos/" + repo.ownerHandle() + "/" + repo.name + "/tree/" + ref;
469
469
List<Crumb> crumbs = new ArrayList<>();
470
470
crumbs.add(new Crumb(ref, treeBase));
471
471
if (path != null && !path.isEmpty())
@@ -501,8 +501,7 @@
501
501
private Repository requireOwner(String owner, String name)
502
502
{
503
503
Repository repo = requireReadable(owner, name);
504
- User user = currentUser.get();
505
- if (user == null || !user.id.equals(repo.owner.id))
504
+ if (!accessPolicy.canAdmin(currentUser.get(), repo))
506
505
{
507
506
// hide existence from non-owners rather than signalling 403
508
507
throw new NotFoundException();
ADD
src/main/resources/db/migration/V14__organisations.sql
+30 -0
@@ -0,0 +1,30 @@
1
+create table organisations
2
+(
3
+ id uuid primary key,
4
+ name varchar(255) not null unique,
5
+ display_name varchar(255),
6
+ created_at timestamptz not null default now()
7
+);
8
+
9
+create table organisation_members
10
+(
11
+ id uuid primary key,
12
+ organisation_id uuid not null references organisations (id) on delete cascade,
13
+ user_id uuid not null references users (id) on delete cascade,
14
+ role varchar(16) not null check (role in ('GUEST', 'MEMBER', 'OWNER')),
15
+ created_at timestamptz not null default now(),
16
+ unique (organisation_id, user_id)
17
+);
18
+
19
+-- A repository is owned by exactly one user or one organisation. The org/user handle namespace is
20
+-- shared; cross-table uniqueness is enforced by the application inside the creating transaction.
21
+alter table repositories
22
+ rename column owner_id to owner_user_id;
23
+alter table repositories
24
+ alter column owner_user_id drop not null;
25
+alter table repositories
26
+ add column owner_org_id uuid references organisations (id);
27
+alter table repositories
28
+ add constraint repositories_exactly_one_owner check (num_nonnulls(owner_user_id, owner_org_id) = 1);
29
+alter table repositories
30
+ add constraint repositories_owner_org_name_unique unique (owner_org_id, name);
MODIFY
src/main/resources/templates/CollaboratorResource/collaborators.html
+2 -2
@@ -17,7 +17,7 @@
17
17
<tr>
18
18
<td>{#avatar user=collaborator.user /} {collaborator.user.username}</td>
19
19
<td class="actions">
20
- <form class="inline" method="post" action="/repos/{repo.owner.username}/{repo.name}/settings/collaborators/{collaborator.user.username}/remove">
20
+ <form class="inline" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/settings/collaborators/{collaborator.user.username}/remove">
21
21
<button class="btn btn-danger btn-sm">Remove</button>
22
22
</form>
23
23
</td>
@@ -26,7 +26,7 @@
26
26
</table>
27
27
{/if}
28
28
<h3>Add collaborator</h3>
29
- <form method="post" action="/repos/{repo.owner.username}/{repo.name}/settings/collaborators">
29
+ <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/settings/collaborators">
30
30
<p><label>Username <input name="username" required></label></p>
31
31
<button class="btn btn-primary">Add</button>
32
32
</form>
MODIFY
src/main/resources/templates/HomeResource/dashboard.html
+6 -6
@@ -1,6 +1,6 @@
1
1
{#include layout}
2
2
{#title}git-shark{/title}
3
-<p><a class="btn btn-primary" href="/repos/new">New repository</a></p>
3
+<p><a class="btn btn-primary" href="/repos/new">New repository</a> <a class="btn btn-secondary" href="/orgs/new">New organisation</a></p>
4
4
5
5
<section class="dashboard-section">
6
6
<h2>Pinned</h2>
@@ -11,10 +11,10 @@
11
11
<tr><th>Repository</th><th>Visibility</th><th></th></tr>
12
12
{#for repo in pinned}
13
13
<tr>
14
- <td>{#repoAvatar repo=repo /} <a class="mono" href="/repos/{repo.owner.username}/{repo.name}">{repo.owner.username}/{repo.name}</a></td>
14
+ <td>{#repoAvatar repo=repo /} <a class="mono" href="/repos/{repo.ownerHandle}/{repo.name}">{repo.ownerHandle}/{repo.name}</a></td>
15
15
<td><span class="badge badge-{repo.visibility.name().toLowerCase()}">{repo.visibility.name().toLowerCase()}</span></td>
16
16
<td class="actions">
17
- <form class="inline" method="post" action="/repos/{repo.owner.username}/{repo.name}/unpin">
17
+ <form class="inline" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/unpin">
18
18
<input type="hidden" name="redirect" value="/">
19
19
<button class="btn-icon pinned" type="submit" title="Unpin repository" aria-label="Unpin repository">
20
20
<svg viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z"/></svg>
@@ -50,19 +50,19 @@
50
50
<tr><th>Repository</th><th>Visibility</th><th>Description</th><th></th></tr>
51
51
{#for row in repositories}
52
52
<tr>
53
- <td>{#repoAvatar repo=row.repo /} <a class="mono" href="/repos/{row.repo.owner.username}/{row.repo.name}">{row.repo.owner.username}/{row.repo.name}</a></td>
53
+ <td>{#repoAvatar repo=row.repo /} <a class="mono" href="/repos/{row.repo.ownerHandle}/{row.repo.name}">{row.repo.ownerHandle}/{row.repo.name}</a></td>
54
54
<td><span class="badge badge-{row.repo.visibility.name().toLowerCase()}">{row.repo.visibility.name().toLowerCase()}</span></td>
55
55
<td class="muted">{row.repo.description ?: ''}</td>
56
56
<td class="actions">
57
57
{#if row.pinned}
58
- <form class="inline" method="post" action="/repos/{row.repo.owner.username}/{row.repo.name}/unpin">
58
+ <form class="inline" method="post" action="/repos/{row.repo.ownerHandle}/{row.repo.name}/unpin">
59
59
<input type="hidden" name="redirect" value="/">
60
60
<button class="btn-icon pinned" type="submit" title="Unpin repository" aria-label="Unpin repository">
61
61
<svg viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z"/></svg>
62
62
</button>
63
63
</form>
64
64
{#else}
65
- <form class="inline" method="post" action="/repos/{row.repo.owner.username}/{row.repo.name}/pin">
65
+ <form class="inline" method="post" action="/repos/{row.repo.ownerHandle}/{row.repo.name}/pin">
66
66
<input type="hidden" name="redirect" value="/">
67
67
<button class="btn-icon" type="submit" title="Pin repository" aria-label="Pin repository">
68
68
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z"/></svg>
MODIFY
src/main/resources/templates/HomeResource/home.html
+1 -1
@@ -22,7 +22,7 @@
22
22
<tr><th>Repository</th><th>Visibility</th><th>Description</th></tr>
23
23
{#for repo in repositories}
24
24
<tr>
25
- <td>{#repoAvatar repo=repo /} <a class="mono" href="/repos/{repo.owner.username}/{repo.name}">{repo.owner.username}/{repo.name}</a></td>
25
+ <td>{#repoAvatar repo=repo /} <a class="mono" href="/repos/{repo.ownerHandle}/{repo.name}">{repo.ownerHandle}/{repo.name}</a></td>
26
26
<td><span class="badge badge-{repo.visibility.name().toLowerCase()}">{repo.visibility.name().toLowerCase()}</span></td>
27
27
<td class="muted">{repo.description ?: ''}</td>
28
28
</tr>
MODIFY
src/main/resources/templates/HomeResource/newRepo.html
+10 -0
@@ -5,6 +5,16 @@
5
5
<p class="error">{error}</p>
6
6
{/if}
7
7
<form method="post" action="/repos">
8
+ {#if !orgs.isEmpty()}
9
+ <p><label>Owner
10
+ <select name="owner">
11
+ <option value="">{cdi:currentUser.get.username} (personal)</option>
12
+ {#for org in orgs}
13
+ <option value="{org.name}">{org.name}</option>
14
+ {/for}
15
+ </select>
16
+ </label></p>
17
+ {/if}
8
18
<p><label>Name <input class="mono" name="name" required pattern="[a-zA-Z0-9._-]+"></label></p>
9
19
<p><label>Visibility
10
20
<select name="visibility">
MODIFY
src/main/resources/templates/IssueResource/editIssue.html
+3 -3
@@ -3,14 +3,14 @@
3
3
<div class="repo-layout">
4
4
{#include RepositoryResource/sidebar nav=nav active='issues' /}
5
5
<section class="repo-main">
6
- <p><a href="/repos/{repo.owner.username}/{repo.name}/issues/{issue.number}">← {issue.title}</a></p>
6
+ <p><a href="/repos/{repo.ownerHandle}/{repo.name}/issues/{issue.number}">← {issue.title}</a></p>
7
7
<h2>Edit issue <span class="issue-no">#{issue.number}</span></h2>
8
- <form class="issue-form" method="post" action="/repos/{repo.owner.username}/{repo.name}/issues/{issue.number}/edit">
8
+ <form class="issue-form" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/issues/{issue.number}/edit">
9
9
<input type="text" name="title" value="{issue.title}" placeholder="Issue title" required autocomplete="off">
10
10
<textarea name="description" placeholder="Description (optional)" rows="6">{issue.description ?: ''}</textarea>
11
11
<div class="form-actions">
12
12
<button type="submit" class="btn btn-primary">Save changes</button>
13
- <a class="btn btn-secondary" href="/repos/{repo.owner.username}/{repo.name}/issues/{issue.number}">Cancel</a>
13
+ <a class="btn btn-secondary" href="/repos/{repo.ownerHandle}/{repo.name}/issues/{issue.number}">Cancel</a>
14
14
</div>
15
15
</form>
16
16
</section>
MODIFY
src/main/resources/templates/IssueResource/issue.html
+4 -4
@@ -3,7 +3,7 @@
3
3
<div class="repo-layout">
4
4
{#include RepositoryResource/sidebar nav=nav active='issues' /}
5
5
<section class="repo-main">
6
- <p><a href="/repos/{repo.owner.username}/{repo.name}/issues">← Issues</a></p>
6
+ <p><a href="/repos/{repo.ownerHandle}/{repo.name}/issues">← Issues</a></p>
7
7
<h2>{issue.title} <span class="issue-no">#{issue.number}</span></h2>
8
8
<p>
9
9
<span class="badge status-{issue.status}">{issue.status.label}</span>
@@ -18,14 +18,14 @@
18
18
{/if}
19
19
{#if owner}
20
20
<div class="issue-manage">
21
- <a class="btn btn-secondary btn-sm" href="/repos/{repo.owner.username}/{repo.name}/issues/{issue.number}/edit">Edit issue</a>
22
- <form class="issue-actions" method="post" action="/repos/{repo.owner.username}/{repo.name}/issues/{issue.number}/status">
21
+ <a class="btn btn-secondary btn-sm" href="/repos/{repo.ownerHandle}/{repo.name}/issues/{issue.number}/edit">Edit issue</a>
22
+ <form class="issue-actions" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/issues/{issue.number}/status">
23
23
<span class="lbl">Move to</span>
24
24
{#for status in statuses}
25
25
<button type="submit" class="btn btn-secondary btn-sm" name="status" value="{status}"{#if status == issue.status} disabled{/if}>{status.label}</button>
26
26
{/for}
27
27
</form>
28
- <form class="issue-remove" method="post" action="/repos/{repo.owner.username}/{repo.name}/issues/{issue.number}/delete"
28
+ <form class="issue-remove" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/issues/{issue.number}/delete"
29
29
onsubmit="return confirm('Delete this issue? This cannot be undone.')">
30
30
<button type="submit" class="btn btn-danger btn-sm">Delete issue</button>
31
31
</form>
MODIFY
src/main/resources/templates/IssueResource/issues.html
+3 -3
@@ -6,7 +6,7 @@
6
6
<div class="issues-head">
7
7
<h2>Issues</h2>
8
8
{#if owner}
9
- <a class="btn btn-primary" href="/repos/{repo.owner.username}/{repo.name}/issues/new">New issue</a>
9
+ <a class="btn btn-primary" href="/repos/{repo.ownerHandle}/{repo.name}/issues/new">New issue</a>
10
10
{/if}
11
11
</div>
12
12
{#if open.isEmpty()}
@@ -14,7 +14,7 @@
14
14
{#else}
15
15
<div class="panel">
16
16
{#for issue in open}
17
- <a class="frow" href="/repos/{repo.owner.username}/{repo.name}/issues/{issue.number}">
17
+ <a class="frow" href="/repos/{repo.ownerHandle}/{repo.name}/issues/{issue.number}">
18
18
<span class="fname">
19
19
<span class="badge status-{issue.status}">{issue.status.label}</span>
20
20
<span class="n">{issue.title}</span>
@@ -29,7 +29,7 @@
29
29
<summary>Archive <span class="ct">{done.size}</span></summary>
30
30
<div class="panel">
31
31
{#for issue in done}
32
- <a class="frow" href="/repos/{repo.owner.username}/{repo.name}/issues/{issue.number}">
32
+ <a class="frow" href="/repos/{repo.ownerHandle}/{repo.name}/issues/{issue.number}">
33
33
<span class="fname">
34
34
<span class="badge status-{issue.status}">{issue.status.label}</span>
35
35
<span class="n">{issue.title}</span>
MODIFY
src/main/resources/templates/IssueResource/newIssue.html
+3 -3
@@ -3,14 +3,14 @@
3
3
<div class="repo-layout">
4
4
{#include RepositoryResource/sidebar nav=nav active='issues' /}
5
5
<section class="repo-main">
6
- <p><a href="/repos/{repo.owner.username}/{repo.name}/issues">← Issues</a></p>
6
+ <p><a href="/repos/{repo.ownerHandle}/{repo.name}/issues">← Issues</a></p>
7
7
<h2>New issue</h2>
8
- <form class="issue-form" method="post" action="/repos/{repo.owner.username}/{repo.name}/issues">
8
+ <form class="issue-form" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/issues">
9
9
<input type="text" name="title" placeholder="Issue title" required autocomplete="off">
10
10
<textarea name="description" placeholder="Description (optional)" rows="6"></textarea>
11
11
<div class="form-actions">
12
12
<button type="submit" class="btn btn-primary">Create issue</button>
13
- <a class="btn btn-secondary" href="/repos/{repo.owner.username}/{repo.name}/issues">Cancel</a>
13
+ <a class="btn btn-secondary" href="/repos/{repo.ownerHandle}/{repo.name}/issues">Cancel</a>
14
14
</div>
15
15
</form>
16
16
</section>
MODIFY
src/main/resources/templates/MergeRequestResource/mergeRequest.html
+5 -5
@@ -3,7 +3,7 @@
3
3
<div class="repo-layout">
4
4
{#include RepositoryResource/sidebar nav=nav active='merge-requests' /}
5
5
<section class="repo-main">
6
-<p><a href="/repos/{repo.owner.username}/{repo.name}/merge-requests">← Merge requests</a></p>
6
+<p><a href="/repos/{repo.ownerHandle}/{repo.name}/merge-requests">← Merge requests</a></p>
7
7
<h2>{mr.title} <span class="issue-no">!{mr.number}</span></h2>
8
8
<p>
9
9
<span class="badge status-{mr.status}">{mr.status.label}</span>
@@ -15,10 +15,10 @@
15
15
{/if}
16
16
{#if owner && mr.status.name() == 'OPEN'}
17
17
<div class="issue-actions">
18
- <form method="post" action="/repos/{repo.owner.username}/{repo.name}/merge-requests/{mr.id}/merge">
18
+ <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.id}/merge">
19
19
<button type="submit" class="btn btn-primary">Merge</button>
20
20
</form>
21
- <form method="post" action="/repos/{repo.owner.username}/{repo.name}/merge-requests/{mr.id}/close"
21
+ <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.id}/close"
22
22
onsubmit="return confirm('Close this merge request without merging?')">
23
23
<button type="submit" class="btn btn-secondary">Close</button>
24
24
</form>
@@ -58,7 +58,7 @@
58
58
</div>
59
59
{#if lv.commentable}
60
60
<div class="dl-comment-form">
61
- <form method="post" action="/repos/{repo.owner.username}/{repo.name}/merge-requests/{mr.id}/comments">
61
+ <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.id}/comments">
62
62
<input type="hidden" name="filePath" value="{file.path}">
63
63
<input type="hidden" name="oldLine" value="{lv.line.oldLine}">
64
64
<input type="hidden" name="newLine" value="{lv.line.newLine}">
@@ -80,7 +80,7 @@
80
80
</div>
81
81
{#if owner || c.author.id == currentUserId}
82
82
<form class="comment-del-form" method="post"
83
- action="/repos/{repo.owner.username}/{repo.name}/merge-requests/{mr.id}/comments/{c.id}/delete">
83
+ action="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.id}/comments/{c.id}/delete">
84
84
<button type="submit" class="comment-del" title="Delete comment" aria-label="Delete comment">
85
85
<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true"><path fill="currentColor" d="M9 3h6l1 2h4v2H4V5h4l1-2Zm-3 6h12l-1 11a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L6 9Z"/></svg>
86
86
</button>
MODIFY
src/main/resources/templates/MergeRequestResource/mergeRequests.html
+3 -3
@@ -6,7 +6,7 @@
6
6
<div class="issues-head">
7
7
<h2>Merge requests</h2>
8
8
{#if owner}
9
- <a class="btn btn-primary" href="/repos/{repo.owner.username}/{repo.name}/merge-requests/new">New merge request</a>
9
+ <a class="btn btn-primary" href="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/new">New merge request</a>
10
10
{/if}
11
11
</div>
12
12
{#if open.isEmpty()}
@@ -14,7 +14,7 @@
14
14
{#else}
15
15
<div class="panel">
16
16
{#for mr in open}
17
- <a class="frow" href="/repos/{repo.owner.username}/{repo.name}/merge-requests/{mr.id}">
17
+ <a class="frow" href="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.id}">
18
18
<span class="fname">
19
19
<span class="badge status-{mr.status}">{mr.status.label}</span>
20
20
<span class="n">{mr.title}</span>
@@ -30,7 +30,7 @@
30
30
<summary>Archive <span class="ct">{closed.size}</span></summary>
31
31
<div class="panel">
32
32
{#for mr in closed}
33
- <a class="frow" href="/repos/{repo.owner.username}/{repo.name}/merge-requests/{mr.id}">
33
+ <a class="frow" href="/repos/{repo.ownerHandle}/{repo.name}/merge-requests/{mr.id}">
34
34
<span class="fname">
35
35
<span class="badge status-{mr.status}">{mr.status.label}</span>
36
36
<span class="n">{mr.title}</span>
MODIFY
src/main/resources/templates/MergeRequestResource/newMergeRequest.html
+3 -3
@@ -3,9 +3,9 @@
3
3
<div class="repo-layout">
4
4
{#include RepositoryResource/sidebar nav=nav active='merge-requests' /}
5
5
<section class="repo-main">
6
- <p><a href="/repos/{repo.owner.username}/{repo.name}/merge-requests">← Merge requests</a></p>
6
+ <p><a href="/repos/{repo.ownerHandle}/{repo.name}/merge-requests">← Merge requests</a></p>
7
7
<h2>New merge request</h2>
8
- <form class="issue-form" method="post" action="/repos/{repo.owner.username}/{repo.name}/merge-requests">
8
+ <form class="issue-form" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/merge-requests">
9
9
<input type="text" name="title" placeholder="Merge request title" required autocomplete="off">
10
10
<div class="mr-branch-picker">
11
11
<label>Merge
@@ -27,7 +27,7 @@
27
27
<textarea name="description" placeholder="Description (optional)" rows="6"></textarea>
28
28
<div class="form-actions">
29
29
<button type="submit" class="btn btn-primary">Create merge request</button>
30
- <a class="btn btn-secondary" href="/repos/{repo.owner.username}/{repo.name}/merge-requests">Cancel</a>
30
+ <a class="btn btn-secondary" href="/repos/{repo.ownerHandle}/{repo.name}/merge-requests">Cancel</a>
31
31
</div>
32
32
</form>
33
33
</section>
ADD
src/main/resources/templates/OrganisationResource/members.html
+48 -0
@@ -0,0 +1,48 @@
1
+{#include layout}
2
+{#title}Members · {org.name} – git-shark{/title}
3
+<p><a href="/orgs/{org.name}">← {org.displayName ?: org.name}</a></p>
4
+<h1>Members</h1>
5
+<p class="muted">Guests read private repositories, members also push, owners manage members and organisation repositories.</p>
6
+{#if error}
7
+<p class="error">{error}</p>
8
+{/if}
9
+<table>
10
+ <tr><th>User</th><th>Role</th><th></th></tr>
11
+ {#for member in members}
12
+ <tr>
13
+ <td>{#avatar user=member.user /} <span class="mono">{member.user.username}</span></td>
14
+ <td>
15
+ <form class="inline" method="post" action="/orgs/{org.name}/members/{member.user.username}/role">
16
+ <select name="role" onchange="this.form.submit()">
17
+ {#for role in roles}
18
+ <option value="{role.name()}" {#if member.role == role}selected{/if}>{role.name().toLowerCase()}</option>
19
+ {/for}
20
+ </select>
21
+ </form>
22
+ </td>
23
+ <td class="actions">
24
+ <form class="inline" method="post" action="/orgs/{org.name}/members/{member.user.username}/remove">
25
+ <button class="btn btn-secondary btn-sm" type="submit">Remove</button>
26
+ </form>
27
+ </td>
28
+ </tr>
29
+ {/for}
30
+</table>
31
+<h2>Add member</h2>
32
+<form method="post" action="/orgs/{org.name}/members">
33
+ <p><label>Username <input class="mono" name="username" required></label></p>
34
+ <p><label>Role
35
+ <select name="role">
36
+ {#for role in roles}
37
+ <option value="{role.name()}">{role.name().toLowerCase()}</option>
38
+ {/for}
39
+ </select>
40
+ </label></p>
41
+ <button class="btn btn-primary">Add member</button>
42
+</form>
43
+<h2>Danger zone</h2>
44
+<form method="post" action="/orgs/{org.name}/delete" onsubmit="return confirm('Delete this organisation?')">
45
+ <p><label>Type the organisation name to confirm deletion <input class="mono" name="confirm"></label></p>
46
+ <button class="btn btn-danger">Delete organisation</button>
47
+</form>
48
+{/include}
ADD
src/main/resources/templates/OrganisationResource/newOrg.html
+13 -0
@@ -0,0 +1,13 @@
1
+{#include layout}
2
+{#title}New organisation – git-shark{/title}
3
+<h1>New organisation</h1>
4
+<p class="muted">An organisation owns repositories just like a user does — its name shares the same namespace as usernames.</p>
5
+{#if error}
6
+<p class="error">{error}</p>
7
+{/if}
8
+<form method="post" action="/orgs">
9
+ <p><label>Name <input class="mono" name="name" required pattern="[a-z0-9][a-z0-9-]*"></label></p>
10
+ <p><label>Display name <input name="displayName"></label></p>
11
+ <button class="btn btn-primary">Create organisation</button>
12
+</form>
13
+{/include}
ADD
src/main/resources/templates/OrganisationResource/org.html
+28 -0
@@ -0,0 +1,28 @@
1
+{#include layout}
2
+{#title}{org.name} – git-shark{/title}
3
+<h1><span class="av-fallback">{org.name.charAt(0)}</span> {org.displayName ?: org.name}</h1>
4
+<p class="muted mono">{org.name}</p>
5
+{#if owner}
6
+<p>
7
+ <a class="btn btn-secondary" href="/orgs/{org.name}/members">Members</a>
8
+ <a class="btn btn-secondary" href="/repos/new">New repository</a>
9
+</p>
10
+{/if}
11
+<section class="dashboard-section">
12
+ <h2>Repositories</h2>
13
+ {#if repos.isEmpty()}
14
+ <p class="muted empty-state">No repositories yet.</p>
15
+ {#else}
16
+ <table>
17
+ <tr><th>Repository</th><th>Visibility</th><th>Description</th></tr>
18
+ {#for repo in repos}
19
+ <tr>
20
+ <td>{#repoAvatar repo=repo /} <a class="mono" href="/repos/{repo.ownerHandle}/{repo.name}">{repo.name}</a></td>
21
+ <td><span class="badge badge-{repo.visibility.name().toLowerCase()}">{repo.visibility.name().toLowerCase()}</span></td>
22
+ <td class="muted">{repo.description ?: ''}</td>
23
+ </tr>
24
+ {/for}
25
+ </table>
26
+ {/if}
27
+</section>
28
+{/include}
MODIFY
src/main/resources/templates/RepositoryResource/blob.html
+1 -1
@@ -10,7 +10,7 @@
10
10
{/for}
11
11
</nav>
12
12
{#if binary}
13
- <p>Binary file. <a class="btn btn-secondary" href="/repos/{repo.owner.username}/{repo.name}/raw/{ref}/{path}">Download</a></p>
13
+ <p>Binary file. <a class="btn btn-secondary" href="/repos/{repo.ownerHandle}/{repo.name}/raw/{ref}/{path}">Download</a></p>
14
14
{#else if markdownHtml}
15
15
<div class="md-toggle" role="group" aria-label="View mode">
16
16
<button type="button" class="btn btn-secondary btn-sm active" data-md-view="rendered">Rendered</button>
MODIFY
src/main/resources/templates/RepositoryResource/branches.html
+1 -1
@@ -8,7 +8,7 @@
8
8
<tr><th>Branch</th><th></th></tr>
9
9
{#for branch in branches}
10
10
<tr>
11
- <td><a href="/repos/{repo.owner.username}/{repo.name}/tree/{branch.name}/">{branch.name}</a></td>
11
+ <td><a href="/repos/{repo.ownerHandle}/{repo.name}/tree/{branch.name}/">{branch.name}</a></td>
12
12
<td>{#if branch.defaultBranch}<span class="badge badge-default">default</span>{/if}</td>
13
13
</tr>
14
14
{/for}
MODIFY
src/main/resources/templates/RepositoryResource/commits.html
+2 -2
@@ -17,10 +17,10 @@
17
17
</table>
18
18
<p class="pagination">
19
19
{#if page > 0}
20
- <a class="btn btn-secondary" href="/repos/{repo.owner.username}/{repo.name}/commits/{ref}?page={prevPage}&size={size}">Newer</a>
20
+ <a class="btn btn-secondary" href="/repos/{repo.ownerHandle}/{repo.name}/commits/{ref}?page={prevPage}&size={size}">Newer</a>
21
21
{/if}
22
22
{#if hasNext}
23
- <a class="btn btn-secondary" href="/repos/{repo.owner.username}/{repo.name}/commits/{ref}?page={nextPage}&size={size}">Older</a>
23
+ <a class="btn btn-secondary" href="/repos/{repo.ownerHandle}/{repo.name}/commits/{ref}?page={nextPage}&size={size}">Older</a>
24
24
{/if}
25
25
</p>
26
26
</section>
MODIFY
src/main/resources/templates/RepositoryResource/overview.html
+8 -8
@@ -1,5 +1,5 @@
1
1
{#include layout}
2
-{#title}{repo.owner.username}/{repo.name} – git-shark{/title}
2
+{#title}{repo.ownerHandle}/{repo.name} – git-shark{/title}
3
3
4
4
<div class="repo-layout">
5
5
@@ -13,13 +13,13 @@
13
13
git push -u origin main</pre>
14
14
{#else}
15
15
<div class="branchbar">
16
- <a class="branchsel" href="/repos/{repo.owner.username}/{repo.name}/branches"><span class="g">⑂</span> {nav.defaultBranch} <span class="car">▾</span></a>
16
+ <a class="branchsel" href="/repos/{repo.ownerHandle}/{repo.name}/branches"><span class="g">⑂</span> {nav.defaultBranch} <span class="car">▾</span></a>
17
17
<span class="meta"><b>{nav.commitCount}</b> commits · <b>{entries.size()}</b> entries</span>
18
18
</div>
19
19
20
20
<div class="panel">
21
21
{#if latestCommit}
22
- <a class="commitrow" href="/repos/{repo.owner.username}/{repo.name}/commits/{nav.defaultBranch}">
22
+ <a class="commitrow" href="/repos/{repo.ownerHandle}/{repo.name}/commits/{nav.defaultBranch}">
23
23
<span class="av">{latestCommit.author.charAt(0)}</span>
24
24
<span class="who">{latestCommit.author}</span>
25
25
<span class="msg">{latestCommit.message}</span>
@@ -28,7 +28,7 @@
28
28
{/if}
29
29
<div class="files">
30
30
{#for entry in entries}
31
- <a class="frow" href="/repos/{repo.owner.username}/{repo.name}/tree/{nav.defaultBranch}/{entry.path}">
31
+ <a class="frow" href="/repos/{repo.ownerHandle}/{repo.name}/tree/{nav.defaultBranch}/{entry.path}">
32
32
<span class="fname">
33
33
<span class="g{#if entry.directory} dir{/if}">{#if entry.directory}▸{#else}≡{/if}</span>
34
34
<span class="n">{entry.name}</span>
@@ -74,10 +74,10 @@
74
74
</div>
75
75
{/if}
76
76
<div class="mirror-actions">
77
- <form class="inline" method="post" action="/repos/{repo.owner.username}/{repo.name}/mirrors/{m.id}/push">
77
+ <form class="inline" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/mirrors/{m.id}/push">
78
78
<button class="btn">Push now</button>
79
79
</form>
80
- <form class="inline" method="post" action="/repos/{repo.owner.username}/{repo.name}/mirrors/{m.id}/delete">
80
+ <form class="inline" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/mirrors/{m.id}/delete">
81
81
<button class="btn btn-danger">Delete</button>
82
82
</form>
83
83
</div>
@@ -85,7 +85,7 @@
85
85
{/for}
86
86
</ul>
87
87
{/if}
88
- <form method="post" action="/repos/{repo.owner.username}/{repo.name}/mirrors" class="mirror-add">
88
+ <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/mirrors" class="mirror-add">
89
89
<p><label>Remote URL <input name="url" placeholder="https://host/owner/repo.git or ssh://git@host/owner/repo.git" required></label></p>
90
90
<p><label>Authentication
91
91
<select name="authType">
@@ -101,7 +101,7 @@
101
101
102
102
<div class="danger-zone">
103
103
<h2>Danger zone</h2>
104
- <form method="post" action="/repos/{repo.owner.username}/{repo.name}/delete">
104
+ <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/delete">
105
105
<p><label>Type the repository name to confirm deletion <input name="confirm"></label></p>
106
106
<button class="btn btn-danger">Delete repository</button>
107
107
</form>
MODIFY
src/main/resources/templates/RepositoryResource/settings.html
+4 -4
@@ -1,5 +1,5 @@
1
1
{#include layout}
2
-{#title}Settings · {repo.owner.username}/{repo.name} – git-shark{/title}
2
+{#title}Settings · {repo.ownerHandle}/{repo.name} – git-shark{/title}
3
3
4
4
<div class="repo-layout">
5
5
@@ -14,15 +14,15 @@
14
14
<h2>Repository image</h2>
15
15
<p class="muted">Shown next to this repository across git-shark. Without one, the owner's profile picture is used.</p>
16
16
{#if repo.hasImage}
17
- <img class="avatar-preview" src="/repos/{repo.owner.username}/{repo.name}/image?v={repo.imageUpdatedAt.toEpochMilli}" alt="Current repository image">
17
+ <img class="avatar-preview" src="/repos/{repo.ownerHandle}/{repo.name}/image?v={repo.imageUpdatedAt.toEpochMilli}" alt="Current repository image">
18
18
{/if}
19
- <form method="post" action="/repos/{repo.owner.username}/{repo.name}/image" enctype="multipart/form-data">
19
+ <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/image" enctype="multipart/form-data">
20
20
<p><input type="file" name="image" accept="image/png,image/jpeg,image/gif,image/webp" required></p>
21
21
<p class="muted">PNG, JPEG, GIF or WebP, up to 2 MB.</p>
22
22
<button class="btn btn-primary">Upload</button>
23
23
</form>
24
24
{#if repo.hasImage}
25
- <form method="post" action="/repos/{repo.owner.username}/{repo.name}/image/delete"
25
+ <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/image/delete"
26
26
onsubmit="return confirm('Remove this repository image?')">
27
27
<button class="btn btn-danger">Remove image</button>
28
28
</form>
MODIFY
src/main/resources/templates/RepositoryResource/sidebar.html
+11 -11
@@ -1,6 +1,6 @@
1
1
<aside class="repo-side">
2
- <div class="owner">{#repoAvatar repo=nav.repo /} {nav.repo.owner.username} /</div>
3
- <div class="repo-name"><a href="/repos/{nav.repo.owner.username}/{nav.repo.name}">{nav.repo.name}</a></div>
2
+ <div class="owner">{#repoAvatar repo=nav.repo /} {nav.repo.ownerHandle} /</div>
3
+ <div class="repo-name"><a href="/repos/{nav.repo.ownerHandle}/{nav.repo.name}">{nav.repo.name}</a></div>
4
4
<span class="tag{#if nav.repo.visibility.name() == 'PRIVATE'} tag-private{/if}"><span class="dot"></span> {nav.repo.visibility.name()}</span>
5
5
{#if nav.repo.description}
6
6
<p class="desc">{nav.repo.description}</p>
@@ -10,7 +10,7 @@
10
10
<span class="g">⤓</span> Clone
11
11
</button>
12
12
{#if nav.loggedIn}
13
- <form class="inline" method="post" action="/repos/{nav.repo.owner.username}/{nav.repo.name}/{#if nav.pinned}unpin{#else}pin{/if}">
13
+ <form class="inline" method="post" action="/repos/{nav.repo.ownerHandle}/{nav.repo.name}/{#if nav.pinned}unpin{#else}pin{/if}">
14
14
<input type="hidden" name="redirect" value="{nav.currentPath}">
15
15
<button class="btn-icon{#if nav.pinned} pinned{/if}" type="submit" title="{#if nav.pinned}Unpin{#else}Pin{/if} repository" aria-label="{#if nav.pinned}Unpin{#else}Pin{/if} repository">
16
16
<svg viewBox="0 0 24 24" fill="{#if nav.pinned}currentColor{#else}none{/if}" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z"/></svg>
@@ -20,17 +20,17 @@
20
20
</div>
21
21
<div class="rule"></div>
22
22
<nav class="repo-nav">
23
- <a class="{#if active == 'code'}active{/if}" href="/repos/{nav.repo.owner.username}/{nav.repo.name}"><span class="g">⌗</span> Code</a>
23
+ <a class="{#if active == 'code'}active{/if}" href="/repos/{nav.repo.ownerHandle}/{nav.repo.name}"><span class="g">⌗</span> Code</a>
24
24
{#if !nav.empty}
25
- <a class="{#if active == 'commits'}active{/if}" href="/repos/{nav.repo.owner.username}/{nav.repo.name}/commits/{nav.defaultBranch}"><span class="g">◴</span> Commits <span class="ct">{nav.commitCount}</span></a>
25
+ <a class="{#if active == 'commits'}active{/if}" href="/repos/{nav.repo.ownerHandle}/{nav.repo.name}/commits/{nav.defaultBranch}"><span class="g">◴</span> Commits <span class="ct">{nav.commitCount}</span></a>
26
26
{/if}
27
- <a class="{#if active == 'branches'}active{/if}" href="/repos/{nav.repo.owner.username}/{nav.repo.name}/branches"><span class="g">⑂</span> Branches <span class="ct">{nav.branchCount}</span></a>
28
- <a class="{#if active == 'tags'}active{/if}" href="/repos/{nav.repo.owner.username}/{nav.repo.name}/tags"><span class="g">⬡</span> Tags <span class="ct">{nav.tagCount}</span></a>
29
- <a class="{#if active == 'issues'}active{/if}" href="/repos/{nav.repo.owner.username}/{nav.repo.name}/issues"><span class="g">◇</span> Issues <span class="ct">{nav.openIssueCount}</span></a>
30
- <a class="{#if active == 'merge-requests'}active{/if}" href="/repos/{nav.repo.owner.username}/{nav.repo.name}/merge-requests"><span class="g">⇄</span> Merge requests <span class="ct">{nav.openMrCount}</span></a>
27
+ <a class="{#if active == 'branches'}active{/if}" href="/repos/{nav.repo.ownerHandle}/{nav.repo.name}/branches"><span class="g">⑂</span> Branches <span class="ct">{nav.branchCount}</span></a>
28
+ <a class="{#if active == 'tags'}active{/if}" href="/repos/{nav.repo.ownerHandle}/{nav.repo.name}/tags"><span class="g">⬡</span> Tags <span class="ct">{nav.tagCount}</span></a>
29
+ <a class="{#if active == 'issues'}active{/if}" href="/repos/{nav.repo.ownerHandle}/{nav.repo.name}/issues"><span class="g">◇</span> Issues <span class="ct">{nav.openIssueCount}</span></a>
30
+ <a class="{#if active == 'merge-requests'}active{/if}" href="/repos/{nav.repo.ownerHandle}/{nav.repo.name}/merge-requests"><span class="g">⇄</span> Merge requests <span class="ct">{nav.openMrCount}</span></a>
31
31
{#if nav.isOwner}
32
- <a class="{#if active == 'settings'}active{/if}" href="/repos/{nav.repo.owner.username}/{nav.repo.name}/settings"><span class="g">⚙</span> Settings</a>
33
- <a class="{#if active == 'collaborators'}active{/if}" href="/repos/{nav.repo.owner.username}/{nav.repo.name}/settings/collaborators"><span class="g">⚇</span> Collaborators</a>
32
+ <a class="{#if active == 'settings'}active{/if}" href="/repos/{nav.repo.ownerHandle}/{nav.repo.name}/settings"><span class="g">⚙</span> Settings</a>
33
+ <a class="{#if active == 'collaborators'}active{/if}" href="/repos/{nav.repo.ownerHandle}/{nav.repo.name}/settings/collaborators"><span class="g">⚇</span> Collaborators</a>
34
34
{/if}
35
35
</nav>
36
36
</aside>
MODIFY
src/main/resources/templates/RepositoryResource/tags.html
+1 -1
@@ -10,7 +10,7 @@
10
10
<table>
11
11
<tr><th>Tag</th></tr>
12
12
{#for tag in tags}
13
- <tr><td><a href="/repos/{repo.owner.username}/{repo.name}/tree/{tag}/">{tag}</a></td></tr>
13
+ <tr><td><a href="/repos/{repo.ownerHandle}/{repo.name}/tree/{tag}/">{tag}</a></td></tr>
14
14
{/for}
15
15
</table>
16
16
{/if}
MODIFY
src/main/resources/templates/RepositoryResource/tree.html
+1 -1
@@ -12,7 +12,7 @@
12
12
<div class="panel">
13
13
<div class="files">
14
14
{#for entry in entries}
15
- <a class="frow" href="/repos/{repo.owner.username}/{repo.name}/tree/{ref}/{entry.path}">
15
+ <a class="frow" href="/repos/{repo.ownerHandle}/{repo.name}/tree/{ref}/{entry.path}">
16
16
<span class="fname">
17
17
<span class="g{#if entry.directory} dir{/if}">{#if entry.directory}▸{#else}≡{/if}</span>
18
18
<span class="n">{entry.name}</span>
MODIFY
src/main/resources/templates/tags/repoAvatar.html
+1 -1
@@ -1 +1 @@
1
-{#if repo.hasImage}<img class="avatar" src="/repos/{repo.owner.username}/{repo.name}/image?v={repo.imageUpdatedAt.toEpochMilli}" alt="{repo.name}">{#else}{#avatar user=repo.owner /}{/if}
1
+{#if repo.hasImage}<img class="avatar" src="/repos/{repo.ownerHandle}/{repo.name}/image?v={repo.imageUpdatedAt.toEpochMilli}" alt="{repo.name}">{#else}{#if repo.ownerUser}{#avatar user=repo.ownerUser /}{#else}<span class="av-fallback">{repo.ownerOrg.name.charAt(0)}</span>{/if}{/if}
ADD
src/test/java/de/workaround/account/OrganisationServiceTest.java
+256 -0
@@ -0,0 +1,256 @@
1
+package de.workaround.account;
2
+
3
+import java.util.UUID;
4
+
5
+import org.junit.jupiter.api.Test;
6
+
7
+import de.workaround.git.ForbiddenOperationException;
8
+import de.workaround.git.GitRepositoryService;
9
+import de.workaround.model.Organisation;
10
+import de.workaround.model.OrganisationMember;
11
+import de.workaround.model.Repository;
12
+import de.workaround.model.User;
13
+import io.quarkus.test.TestTransaction;
14
+import io.quarkus.test.junit.QuarkusTest;
15
+import jakarta.inject.Inject;
16
+
17
+import static org.junit.jupiter.api.Assertions.assertEquals;
18
+import static org.junit.jupiter.api.Assertions.assertThrows;
19
+import static org.junit.jupiter.api.Assertions.assertTrue;
20
+
21
+/**
22
+ * Organisation lifecycle and membership rules: the creator becomes owner, the handle namespace is
23
+ * shared with usernames in both directions, only owners manage members, and the last owner can
24
+ * neither be removed nor downgraded.
25
+ */
26
+@QuarkusTest
27
+class OrganisationServiceTest
28
+{
29
+ @Inject
30
+ OrganisationService organisations;
31
+
32
+ @Inject
33
+ UsernameService usernames;
34
+
35
+ @Inject
36
+ GitRepositoryService repositories;
37
+
38
+ @Inject
39
+ OrganisationMember.Repo members;
40
+
41
+ @Inject
42
+ User.Repo users;
43
+
44
+ private User persistUser()
45
+ {
46
+ User user = new User();
47
+ user.oidcSub = "sub-" + UUID.randomUUID();
48
+ user.username = "org-user-" + UUID.randomUUID().toString().substring(0, 8);
49
+ user.persist();
50
+ return user;
51
+ }
52
+
53
+ private static String uniqueName()
54
+ {
55
+ return "org-" + UUID.randomUUID().toString().substring(0, 8);
56
+ }
57
+
58
+ @Test
59
+ @TestTransaction
60
+ void createMakesCreatorOwner()
61
+ {
62
+ User creator = persistUser();
63
+ Organisation org = organisations.create(creator, uniqueName(), "ACME Inc.");
64
+
65
+ assertEquals(OrganisationMember.Role.OWNER, organisations.roleOf(creator, org).orElseThrow());
66
+ assertEquals(1, organisations.members(org).size());
67
+ }
68
+
69
+ @Test
70
+ @TestTransaction
71
+ void createRejectsNameMatchingExistingUsername()
72
+ {
73
+ User creator = persistUser();
74
+ assertThrows(InvalidOrganisationException.class,
75
+ () -> organisations.create(creator, creator.username, null));
76
+ }
77
+
78
+ @Test
79
+ @TestTransaction
80
+ void createRejectsNameMatchingExistingOrganisation()
81
+ {
82
+ User creator = persistUser();
83
+ String name = uniqueName();
84
+ organisations.create(creator, name, null);
85
+
86
+ assertThrows(InvalidOrganisationException.class, () -> organisations.create(creator, name, null));
87
+ }
88
+
89
+ @Test
90
+ @TestTransaction
91
+ void createRejectsInvalidHandle()
92
+ {
93
+ User creator = persistUser();
94
+ assertThrows(InvalidOrganisationException.class, () -> organisations.create(creator, "Bad.Name", null));
95
+ }
96
+
97
+ @Test
98
+ @TestTransaction
99
+ void usernameChoiceRejectsExistingOrganisationName()
100
+ {
101
+ User creator = persistUser();
102
+ String name = uniqueName();
103
+ organisations.create(creator, name, null);
104
+
105
+ User newcomer = new User();
106
+ newcomer.oidcSub = "sub-" + UUID.randomUUID();
107
+ newcomer.persist();
108
+ assertThrows(UsernameTakenException.class, () -> usernames.choose(newcomer, name));
109
+ }
110
+
111
+ @Test
112
+ @TestTransaction
113
+ void ownerAddsMembersWithRoles()
114
+ {
115
+ User creator = persistUser();
116
+ User guest = persistUser();
117
+ User member = persistUser();
118
+ Organisation org = organisations.create(creator, uniqueName(), null);
119
+
120
+ organisations.addMember(creator, org, guest.username, OrganisationMember.Role.GUEST);
121
+ organisations.addMember(creator, org, member.username, OrganisationMember.Role.MEMBER);
122
+
123
+ assertEquals(OrganisationMember.Role.GUEST, organisations.roleOf(guest, org).orElseThrow());
124
+ assertEquals(OrganisationMember.Role.MEMBER, organisations.roleOf(member, org).orElseThrow());
125
+ assertEquals(3, organisations.members(org).size());
126
+ }
127
+
128
+ @Test
129
+ @TestTransaction
130
+ void addingUnknownUsernameFails()
131
+ {
132
+ User creator = persistUser();
133
+ Organisation org = organisations.create(creator, uniqueName(), null);
134
+
135
+ assertThrows(InvalidOrganisationException.class,
136
+ () -> organisations.addMember(creator, org, "does-not-exist", OrganisationMember.Role.MEMBER));
137
+ }
138
+
139
+ @Test
140
+ @TestTransaction
141
+ void addingExistingMemberFails()
142
+ {
143
+ User creator = persistUser();
144
+ User member = persistUser();
145
+ Organisation org = organisations.create(creator, uniqueName(), null);
146
+ organisations.addMember(creator, org, member.username, OrganisationMember.Role.MEMBER);
147
+
148
+ assertThrows(InvalidOrganisationException.class,
149
+ () -> organisations.addMember(creator, org, member.username, OrganisationMember.Role.GUEST));
150
+ }
151
+
152
+ @Test
153
+ @TestTransaction
154
+ void nonOwnerCannotManageMembers()
155
+ {
156
+ User creator = persistUser();
157
+ User member = persistUser();
158
+ User outsider = persistUser();
159
+ Organisation org = organisations.create(creator, uniqueName(), null);
160
+ organisations.addMember(creator, org, member.username, OrganisationMember.Role.MEMBER);
161
+
162
+ assertThrows(ForbiddenOperationException.class,
163
+ () -> organisations.addMember(member, org, outsider.username, OrganisationMember.Role.GUEST));
164
+ assertThrows(ForbiddenOperationException.class,
165
+ () -> organisations.removeMember(member, org, creator.username));
166
+ assertThrows(ForbiddenOperationException.class,
167
+ () -> organisations.changeRole(member, org, creator.username, OrganisationMember.Role.MEMBER));
168
+ }
169
+
170
+ @Test
171
+ @TestTransaction
172
+ void ownerChangesRole()
173
+ {
174
+ User creator = persistUser();
175
+ User member = persistUser();
176
+ Organisation org = organisations.create(creator, uniqueName(), null);
177
+ organisations.addMember(creator, org, member.username, OrganisationMember.Role.GUEST);
178
+
179
+ organisations.changeRole(creator, org, member.username, OrganisationMember.Role.OWNER);
180
+
181
+ assertEquals(OrganisationMember.Role.OWNER, organisations.roleOf(member, org).orElseThrow());
182
+ }
183
+
184
+ @Test
185
+ @TestTransaction
186
+ void lastOwnerCannotBeRemoved()
187
+ {
188
+ User creator = persistUser();
189
+ Organisation org = organisations.create(creator, uniqueName(), null);
190
+
191
+ assertThrows(InvalidOrganisationException.class,
192
+ () -> organisations.removeMember(creator, org, creator.username));
193
+ }
194
+
195
+ @Test
196
+ @TestTransaction
197
+ void lastOwnerCannotBeDowngraded()
198
+ {
199
+ User creator = persistUser();
200
+ Organisation org = organisations.create(creator, uniqueName(), null);
201
+
202
+ assertThrows(InvalidOrganisationException.class,
203
+ () -> organisations.changeRole(creator, org, creator.username, OrganisationMember.Role.MEMBER));
204
+ }
205
+
206
+ @Test
207
+ @TestTransaction
208
+ void firstOwnerCanLeaveOnceASecondOwnerExists()
209
+ {
210
+ User creator = persistUser();
211
+ User successor = persistUser();
212
+ Organisation org = organisations.create(creator, uniqueName(), null);
213
+ organisations.addMember(creator, org, successor.username, OrganisationMember.Role.OWNER);
214
+
215
+ organisations.removeMember(creator, org, creator.username);
216
+
217
+ assertTrue(organisations.roleOf(creator, org).isEmpty());
218
+ assertEquals(OrganisationMember.Role.OWNER, organisations.roleOf(successor, org).orElseThrow());
219
+ }
220
+
221
+ @Test
222
+ @TestTransaction
223
+ void ownerDeletesEmptyOrganisation()
224
+ {
225
+ User creator = persistUser();
226
+ Organisation org = organisations.create(creator, uniqueName(), null);
227
+
228
+ organisations.delete(creator, org);
229
+
230
+ assertTrue(organisations.find(org.name).isEmpty());
231
+ }
232
+
233
+ @Test
234
+ @TestTransaction
235
+ void deleteFailsWhileOrganisationOwnsRepositories()
236
+ {
237
+ User creator = persistUser();
238
+ Organisation org = organisations.create(creator, uniqueName(), null);
239
+ repositories.create(org, "keeper", Repository.Visibility.PUBLIC, null);
240
+
241
+ assertThrows(InvalidOrganisationException.class, () -> organisations.delete(creator, org));
242
+ }
243
+
244
+ @Test
245
+ @TestTransaction
246
+ void nonOwnerCannotDeleteOrganisation()
247
+ {
248
+ User creator = persistUser();
249
+ User member = persistUser();
250
+ Organisation org = organisations.create(creator, uniqueName(), null);
251
+ organisations.addMember(creator, org, member.username, OrganisationMember.Role.MEMBER);
252
+
253
+ assertThrows(ForbiddenOperationException.class, () -> organisations.delete(member, org));
254
+ }
255
+
256
+}
MODIFY
src/test/java/de/workaround/federation/FederationFollowingTest.java
+1 -1
@@ -122,7 +122,7 @@
122
122
@Transactional
123
123
boolean hasFollower(Repository repo, String followerActorId)
124
124
{
125
- Repository managed = service.find(repo.owner.username, repo.name).orElseThrow();
125
+ Repository managed = service.find(repo.ownerHandle(), repo.name).orElseThrow();
126
126
return followers.findByRepositoryAndFollowerActorId(managed, followerActorId).isPresent();
127
127
}
128
128
MODIFY
src/test/java/de/workaround/federation/FederationHandshakeTest.java
+2 -2
@@ -141,14 +141,14 @@
141
141
@Transactional
142
142
boolean hasFollower(Repository repo, String actorId)
143
143
{
144
- Repository managed = service.find(repo.owner.username, repo.name).orElseThrow();
144
+ Repository managed = service.find(repo.ownerHandle(), repo.name).orElseThrow();
145
145
return followers.findByRepositoryAndFollowerActorId(managed, actorId).isPresent();
146
146
}
147
147
148
148
@Transactional
149
149
long followerCount(Repository repo, String actorId)
150
150
{
151
- Repository managed = service.find(repo.owner.username, repo.name).orElseThrow();
151
+ Repository managed = service.find(repo.ownerHandle(), repo.name).orElseThrow();
152
152
return followers.findByRepository(managed).stream().filter(f -> f.followerActorId.equals(actorId)).count();
153
153
}
154
154
MODIFY
src/test/java/de/workaround/federation/FederationPushTest.java
+2 -2
@@ -126,7 +126,7 @@
126
126
@Transactional
127
127
void addFollower(Repository repo, String followerActorId, String inbox)
128
128
{
129
- Repository managed = service.find(repo.owner.username, repo.name).orElseThrow();
129
+ Repository managed = service.find(repo.ownerHandle(), repo.name).orElseThrow();
130
130
RepositoryFollower follower = new RepositoryFollower();
131
131
follower.repository = managed;
132
132
follower.followerActorId = followerActorId;
@@ -143,7 +143,7 @@
143
143
@Transactional
144
144
void addFollowerWithoutCache(Repository repo, String followerActorId)
145
145
{
146
- Repository managed = service.find(repo.owner.username, repo.name).orElseThrow();
146
+ Repository managed = service.find(repo.ownerHandle(), repo.name).orElseThrow();
147
147
RepositoryFollower follower = new RepositoryFollower();
148
148
follower.repository = managed;
149
149
follower.followerActorId = followerActorId;
MODIFY
src/test/java/de/workaround/git/AccessPolicyTest.java
+102 -5
@@ -1,10 +1,14 @@
1
1
package de.workaround.git;
2
2
3
+import java.util.Map;
4
+import java.util.Optional;
3
5
import java.util.Set;
4
6
import java.util.UUID;
5
7
6
8
import org.junit.jupiter.api.Test;
7
9
10
+import de.workaround.model.Organisation;
11
+import de.workaround.model.OrganisationMember;
8
12
import de.workaround.model.Repository;
9
13
import de.workaround.model.User;
10
14
@@ -19,9 +23,21 @@
19
23
20
24
private final User collaborator = user();
21
25
22
- /** Policy whose collaborator lookup knows exactly one collaborator. */
26
+ private final User orgGuest = user();
27
+
28
+ private final User orgMember = user();
29
+
30
+ private final User orgOwner = user();
31
+
32
+ private final Organisation org = organisation();
33
+
34
+ /** Policy whose collaborator lookup knows exactly one collaborator and one org with three members. */
23
35
private final AccessPolicy policy = new AccessPolicy(
24
- (user, repository) -> Set.of(collaborator.id).contains(user.id));
36
+ (user, repository) -> Set.of(collaborator.id).contains(user.id),
37
+ (user, organisation) -> Optional.ofNullable(Map.of(
38
+ orgGuest.id, OrganisationMember.Role.GUEST,
39
+ orgMember.id, OrganisationMember.Role.MEMBER,
40
+ orgOwner.id, OrganisationMember.Role.OWNER).get(user.id)));
25
41
26
42
@Test
27
43
void ownerReadsAndWritesOwnRepository()
@@ -73,14 +89,78 @@
73
89
@Test
74
90
void anonymousIsNeverTreatedAsCollaborator()
75
91
{
76
- // a lookup that would blow up on null users must never be consulted for anonymous requests
92
+ // lookups that would blow up on null users must never be consulted for anonymous requests
77
93
AccessPolicy paranoid = new AccessPolicy((user, repository) -> {
78
- throw new AssertionError("lookup must not be called for anonymous users");
94
+ throw new AssertionError("collaborator lookup must not be called for anonymous users");
95
+ }, (user, organisation) -> {
96
+ throw new AssertionError("org role lookup must not be called for anonymous users");
79
97
});
80
98
Repository repo = repo(owner, Repository.Visibility.PRIVATE);
81
99
82
100
assertFalse(paranoid.canRead(null, repo));
83
101
assertFalse(paranoid.canWrite(null, repo));
102
+ assertFalse(paranoid.canAdmin(null, repo));
103
+ }
104
+
105
+ @Test
106
+ void personalOwnerAdminsButCollaboratorDoesNot()
107
+ {
108
+ Repository repo = repo(owner, Repository.Visibility.PRIVATE);
109
+
110
+ assertTrue(policy.canAdmin(owner, repo));
111
+ assertFalse(policy.canAdmin(collaborator, repo));
112
+ assertFalse(policy.canAdmin(stranger, repo));
113
+ }
114
+
115
+ @Test
116
+ void orgGuestReadsPrivateOrgRepositoryButNeverWrites()
117
+ {
118
+ Repository repo = orgRepo(Repository.Visibility.PRIVATE);
119
+
120
+ assertTrue(policy.canRead(orgGuest, repo));
121
+ assertFalse(policy.canWrite(orgGuest, repo));
122
+ assertFalse(policy.canAdmin(orgGuest, repo));
123
+ }
124
+
125
+ @Test
126
+ void orgMemberWritesButDoesNotAdmin()
127
+ {
128
+ Repository repo = orgRepo(Repository.Visibility.PRIVATE);
129
+
130
+ assertTrue(policy.canRead(orgMember, repo));
131
+ assertTrue(policy.canWrite(orgMember, repo));
132
+ assertFalse(policy.canAdmin(orgMember, repo));
133
+ }
134
+
135
+ @Test
136
+ void orgOwnerReadsWritesAndAdmins()
137
+ {
138
+ Repository repo = orgRepo(Repository.Visibility.PRIVATE);
139
+
140
+ assertTrue(policy.canRead(orgOwner, repo));
141
+ assertTrue(policy.canWrite(orgOwner, repo));
142
+ assertTrue(policy.canAdmin(orgOwner, repo));
143
+ }
144
+
145
+ @Test
146
+ void strangerCannotAccessPrivateOrgRepository()
147
+ {
148
+ Repository repo = orgRepo(Repository.Visibility.PRIVATE);
149
+
150
+ assertFalse(policy.canRead(stranger, repo));
151
+ assertFalse(policy.canWrite(stranger, repo));
152
+ assertFalse(policy.canAdmin(stranger, repo));
153
+ }
154
+
155
+ @Test
156
+ void publicOrgRepositoryIsWorldReadableButGuardedForWrites()
157
+ {
158
+ Repository repo = orgRepo(Repository.Visibility.PUBLIC);
159
+
160
+ assertTrue(policy.canRead(stranger, repo));
161
+ assertTrue(policy.canRead(null, repo));
162
+ assertFalse(policy.canWrite(stranger, repo));
163
+ assertTrue(policy.canWrite(orgMember, repo));
84
164
}
85
165
86
166
private static User user()
@@ -91,11 +171,28 @@
91
171
return user;
92
172
}
93
173
174
+ private static Organisation organisation()
175
+ {
176
+ Organisation org = new Organisation();
177
+ org.id = UUID.randomUUID();
178
+ org.name = "o-" + org.id;
179
+ return org;
180
+ }
181
+
94
182
private static Repository repo(User owner, Repository.Visibility visibility)
95
183
{
96
184
Repository repo = new Repository();
97
185
repo.id = UUID.randomUUID();
98
- repo.owner = owner;
186
+ repo.ownerUser = owner;
187
+ repo.visibility = visibility;
188
+ return repo;
189
+ }
190
+
191
+ private Repository orgRepo(Repository.Visibility visibility)
192
+ {
193
+ Repository repo = new Repository();
194
+ repo.id = UUID.randomUUID();
195
+ repo.ownerOrg = org;
99
196
repo.visibility = visibility;
100
197
return repo;
101
198
}
MODIFY
src/test/java/de/workaround/git/IssueServiceTest.java
+1 -1
@@ -222,7 +222,7 @@
222
222
{
223
223
Repository repo = new Repository();
224
224
repo.name = name;
225
- repo.owner = owner;
225
+ repo.ownerUser = owner;
226
226
repo.visibility = Repository.Visibility.PUBLIC;
227
227
repo.persist();
228
228
return repo;
ADD
src/test/java/de/workaround/git/OrgRepositoryTest.java
+114 -0
@@ -0,0 +1,114 @@
1
+package de.workaround.git;
2
+
3
+import java.nio.file.Files;
4
+import java.util.UUID;
5
+
6
+import org.junit.jupiter.api.Test;
7
+
8
+import de.workaround.account.OrganisationService;
9
+import de.workaround.model.Organisation;
10
+import de.workaround.model.OrganisationMember;
11
+import de.workaround.model.Repository;
12
+import de.workaround.model.User;
13
+import io.quarkus.test.junit.QuarkusTest;
14
+import jakarta.inject.Inject;
15
+import jakarta.transaction.Transactional;
16
+
17
+import static org.junit.jupiter.api.Assertions.assertEquals;
18
+import static org.junit.jupiter.api.Assertions.assertThrows;
19
+import static org.junit.jupiter.api.Assertions.assertTrue;
20
+
21
+/**
22
+ * Organisation-owned repositories resolve through the same {@code {owner}/{name}} lookup as user
23
+ * repositories, live on disk under the organisation's id, and are administered by org owners only.
24
+ */
25
+@QuarkusTest
26
+class OrgRepositoryTest
27
+{
28
+ @Inject
29
+ GitRepositoryService service;
30
+
31
+ @Inject
32
+ OrganisationService organisations;
33
+
34
+ @Inject
35
+ User.Repo users;
36
+
37
+ @Test
38
+ void orgRepositoryResolvesByOrgHandle()
39
+ {
40
+ Organisation org = orgOwnedBy(persistUser());
41
+ Repository repo = service.create(org, "resolve-me", Repository.Visibility.PUBLIC, null);
42
+
43
+ Repository found = service.find(org.name, "resolve-me").orElseThrow();
44
+ assertEquals(repo.id, found.id);
45
+ assertEquals(org.name, found.ownerHandle());
46
+ }
47
+
48
+ @Test
49
+ void dotGitSuffixResolvesForOrgRepositories()
50
+ {
51
+ Organisation org = orgOwnedBy(persistUser());
52
+ service.create(org, "suffixed", Repository.Visibility.PUBLIC, null);
53
+
54
+ assertTrue(service.find(org.name, "suffixed.git").isPresent());
55
+ }
56
+
57
+ @Test
58
+ void orgRepositoryStorageIsKeyedByOrgId()
59
+ {
60
+ Organisation org = orgOwnedBy(persistUser());
61
+ Repository repo = service.create(org, "on-disk", Repository.Visibility.PUBLIC, null);
62
+
63
+ assertTrue(service.repositoryPath(repo).toString().contains(org.id.toString()));
64
+ assertTrue(Files.isDirectory(service.repositoryPath(repo)));
65
+ }
66
+
67
+ @Test
68
+ void duplicateNamePerOrganisationIsRejected()
69
+ {
70
+ Organisation org = orgOwnedBy(persistUser());
71
+ service.create(org, "dup", Repository.Visibility.PUBLIC, null);
72
+
73
+ assertThrows(RepositoryAlreadyExistsException.class,
74
+ () -> service.create(org, "dup", Repository.Visibility.PUBLIC, null));
75
+ }
76
+
77
+ @Test
78
+ void orgOwnerDeletesRepositoryButMemberCannot()
79
+ {
80
+ User creator = persistUser();
81
+ User member = persistUser();
82
+ Organisation org = orgOwnedBy(creator);
83
+ addMember(creator, org, member, OrganisationMember.Role.MEMBER);
84
+ Repository repo = service.create(org, "guarded-delete", Repository.Visibility.PUBLIC, null);
85
+
86
+ assertThrows(ForbiddenOperationException.class, () -> service.delete(member, repo));
87
+
88
+ service.delete(creator, repo);
89
+ assertTrue(service.find(org.name, "guarded-delete").isEmpty());
90
+ }
91
+
92
+ @Transactional
93
+ User persistUser()
94
+ {
95
+ User user = new User();
96
+ user.oidcSub = "sub-" + UUID.randomUUID();
97
+ user.username = "orgrepo-" + UUID.randomUUID().toString().substring(0, 8);
98
+ user.persist();
99
+ return user;
100
+ }
101
+
102
+ @Transactional
103
+ Organisation orgOwnedBy(User creator)
104
+ {
105
+ return organisations.create(creator, "orgrepo-" + UUID.randomUUID().toString().substring(0, 8), null);
106
+ }
107
+
108
+ @Transactional
109
+ void addMember(User actor, Organisation org, User user, OrganisationMember.Role role)
110
+ {
111
+ organisations.addMember(actor, org, user.username, role);
112
+ }
113
+
114
+}
MODIFY
src/test/java/de/workaround/git/RepositoryPinServiceTest.java
+1 -1
@@ -90,7 +90,7 @@
90
90
{
91
91
Repository repo = new Repository();
92
92
repo.name = name;
93
- repo.owner = owner;
93
+ repo.ownerUser = owner;
94
94
repo.visibility = Repository.Visibility.PUBLIC;
95
95
repo.persist();
96
96
return repo;
MODIFY
src/test/java/de/workaround/http/GitSmartHttpTest.java
+1 -1
@@ -150,7 +150,7 @@
150
150
@Transactional
151
151
void addCollaborator(Repository repo, User user)
152
152
{
153
- collaboratorService.add(repo.owner, repo, user.username);
153
+ collaboratorService.add(repo.ownerUser, repo, user.username);
154
154
}
155
155
156
156
@Test
ADD
src/test/java/de/workaround/http/OrgGitHttpTest.java
+175 -0
@@ -0,0 +1,175 @@
1
+package de.workaround.http;
2
+
3
+import java.net.URL;
4
+import java.nio.file.Files;
5
+import java.nio.file.Path;
6
+import java.util.UUID;
7
+
8
+import org.eclipse.jgit.api.Git;
9
+import org.eclipse.jgit.api.errors.InvalidRemoteException;
10
+import org.eclipse.jgit.api.errors.TransportException;
11
+import org.eclipse.jgit.lib.ObjectId;
12
+import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
13
+import org.eclipse.jgit.transport.RefSpec;
14
+import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
15
+import org.junit.jupiter.api.Test;
16
+
17
+import de.workaround.account.OrganisationService;
18
+import de.workaround.git.GitRepositoryService;
19
+import de.workaround.model.Organisation;
20
+import de.workaround.model.OrganisationMember;
21
+import de.workaround.model.Repository;
22
+import de.workaround.model.User;
23
+import io.quarkus.test.common.http.TestHTTPResource;
24
+import io.quarkus.test.junit.QuarkusTest;
25
+import jakarta.inject.Inject;
26
+import jakarta.transaction.Transactional;
27
+
28
+import static org.junit.jupiter.api.Assertions.assertEquals;
29
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
30
+import static org.junit.jupiter.api.Assertions.assertThrows;
31
+import static org.junit.jupiter.api.Assertions.assertTrue;
32
+
33
+/**
34
+ * Org member levels over git smart HTTP: guests clone private org repositories but cannot push,
35
+ * members push, non-members cannot even see private org repositories.
36
+ */
37
+@QuarkusTest
38
+class OrgGitHttpTest
39
+{
40
+ @Inject
41
+ GitRepositoryService service;
42
+
43
+ @Inject
44
+ OrganisationService organisations;
45
+
46
+ @Inject
47
+ AccessTokenService tokenService;
48
+
49
+ @TestHTTPResource("/git")
50
+ URL gitBase;
51
+
52
+ @Test
53
+ void guestClonesPrivateOrgRepositoryButPushIsRejected() throws Exception
54
+ {
55
+ User creator = persistUser();
56
+ User guest = persistUser();
57
+ Organisation org = createOrg(creator);
58
+ addMember(creator, org, guest, OrganisationMember.Role.GUEST);
59
+ Repository repo = service.create(org, "org-guest", Repository.Visibility.PRIVATE, null);
60
+ GitSmartHttpTest.seedCommit(service.repositoryPath(repo));
61
+ ObjectId before = mainRef(service.repositoryPath(repo));
62
+ var credentials = new UsernamePasswordCredentialsProvider(guest.username, createToken(guest));
63
+
64
+ Path work = Files.createTempDirectory("org-guest");
65
+ try (Git git = Git.cloneRepository().setURI(httpUrl(org, "org-guest")).setDirectory(work.toFile())
66
+ .setCredentialsProvider(credentials).call())
67
+ {
68
+ assertTrue(Files.exists(work.resolve("README.md")), "guest must clone the private org repository");
69
+ commitFile(git, work, "guest.txt");
70
+ assertThrows(TransportException.class, () -> git.push()
71
+ .setCredentialsProvider(credentials)
72
+ .setRefSpecs(new RefSpec("HEAD:refs/heads/main"))
73
+ .call());
74
+ }
75
+
76
+ assertEquals(before, mainRef(service.repositoryPath(repo)), "refs must not change on rejected guest push");
77
+ }
78
+
79
+ @Test
80
+ void memberPushesToPrivateOrgRepository() throws Exception
81
+ {
82
+ User creator = persistUser();
83
+ User member = persistUser();
84
+ Organisation org = createOrg(creator);
85
+ addMember(creator, org, member, OrganisationMember.Role.MEMBER);
86
+ Repository repo = service.create(org, "org-member", Repository.Visibility.PRIVATE, null);
87
+ GitSmartHttpTest.seedCommit(service.repositoryPath(repo));
88
+ ObjectId before = mainRef(service.repositoryPath(repo));
89
+ var credentials = new UsernamePasswordCredentialsProvider(member.username, createToken(member));
90
+
91
+ Path work = Files.createTempDirectory("org-member");
92
+ try (Git git = Git.cloneRepository().setURI(httpUrl(org, "org-member")).setDirectory(work.toFile())
93
+ .setCredentialsProvider(credentials).call())
94
+ {
95
+ commitFile(git, work, "member.txt");
96
+ git.push()
97
+ .setCredentialsProvider(credentials)
98
+ .setRefSpecs(new RefSpec("HEAD:refs/heads/main"))
99
+ .call();
100
+ }
101
+
102
+ assertNotEquals(before, mainRef(service.repositoryPath(repo)), "member push must advance main");
103
+ }
104
+
105
+ @Test
106
+ void nonMemberCannotClonePrivateOrgRepository() throws Exception
107
+ {
108
+ User creator = persistUser();
109
+ User outsider = persistUser();
110
+ Organisation org = createOrg(creator);
111
+ Repository repo = service.create(org, "org-hidden", Repository.Visibility.PRIVATE, null);
112
+ GitSmartHttpTest.seedCommit(service.repositoryPath(repo));
113
+ var credentials = new UsernamePasswordCredentialsProvider(outsider.username, createToken(outsider));
114
+
115
+ // authenticated but not allowed: the server hides the repository (404), which JGit
116
+ // surfaces as InvalidRemoteException rather than an auth failure
117
+ Path work = Files.createTempDirectory("org-outsider");
118
+ assertThrows(InvalidRemoteException.class, () -> Git.cloneRepository()
119
+ .setURI(httpUrl(org, "org-hidden"))
120
+ .setDirectory(work.toFile())
121
+ .setCredentialsProvider(credentials)
122
+ .call());
123
+ }
124
+
125
+ private String httpUrl(Organisation org, String repoName)
126
+ {
127
+ return gitBase + "/" + org.name + "/" + repoName + ".git";
128
+ }
129
+
130
+ @Transactional
131
+ Organisation createOrg(User creator)
132
+ {
133
+ return organisations.create(creator, "orghttp-" + UUID.randomUUID().toString().substring(0, 8), null);
134
+ }
135
+
136
+ @Transactional
137
+ void addMember(User actor, Organisation org, User user, OrganisationMember.Role role)
138
+ {
139
+ organisations.addMember(actor, org, user.username, role);
140
+ }
141
+
142
+ @Transactional
143
+ String createToken(User user)
144
+ {
145
+ return tokenService.create(user, "test").plaintext();
146
+ }
147
+
148
+ @Transactional
149
+ User persistUser()
150
+ {
151
+ String name = "orghttp-" + UUID.randomUUID();
152
+ User user = new User();
153
+ user.oidcSub = "sub-" + name;
154
+ user.username = name;
155
+ user.persist();
156
+ return user;
157
+ }
158
+
159
+ private static void commitFile(Git git, Path work, String fileName) throws Exception
160
+ {
161
+ Files.writeString(work.resolve(fileName), "content\n");
162
+ git.add().addFilepattern(".").call();
163
+ git.commit().setMessage("add " + fileName).setSign(false)
164
+ .setAuthor("t", "t@example.com").setCommitter("t", "t@example.com").call();
165
+ }
166
+
167
+ private static ObjectId mainRef(Path barePath) throws Exception
168
+ {
169
+ try (var repo = new FileRepositoryBuilder().setGitDir(barePath.toFile()).setMustExist(true).build())
170
+ {
171
+ return repo.resolve("refs/heads/main");
172
+ }
173
+ }
174
+
175
+}
MODIFY
src/test/java/de/workaround/mirror/MirrorPushTest.java
+2 -2
@@ -168,8 +168,8 @@
168
168
169
169
private PushMirror createHttpMirror(User actor, Repository src, Repository dst, String dstToken)
170
170
{
171
- String url = gitBase + "/" + dst.owner.username + "/" + dst.name + ".git";
172
- return mirrorService.create(actor, src, url, PushMirror.AuthType.HTTPS, dst.owner.username, dstToken);
171
+ String url = gitBase + "/" + dst.ownerHandle() + "/" + dst.name + ".git";
172
+ return mirrorService.create(actor, src, url, PushMirror.AuthType.HTTPS, dst.ownerHandle(), dstToken);
173
173
}
174
174
175
175
private void pushBranch(Repository src, String branch) throws Exception
MODIFY
src/test/java/de/workaround/mirror/MirrorSettingsTest.java
+1 -1
@@ -234,7 +234,7 @@
234
234
235
235
private PushMirror createMirror(Repository repo)
236
236
{
237
- return mirrorService.create(repo.owner, repo, "https://mirror-target.example/owner/repo.git",
237
+ return mirrorService.create(repo.ownerUser, repo, "https://mirror-target.example/owner/repo.git",
238
238
PushMirror.AuthType.HTTPS, "deploy", "token");
239
239
}
240
240
MODIFY
src/test/java/de/workaround/model/EntityPersistenceTest.java
+3 -3
@@ -47,15 +47,15 @@
47
47
48
48
Repository repo = new Repository();
49
49
repo.name = "project";
50
- repo.owner = owner;
50
+ repo.ownerUser = owner;
51
51
repo.visibility = Repository.Visibility.PUBLIC;
52
52
repo.description = "demo";
53
53
repo.persist();
54
54
55
55
assertNotNull(repo.id);
56
- Repository found = repositories.findByOwnerAndName(owner, "project").orElseThrow();
56
+ Repository found = repositories.findByOwnerUserAndName(owner, "project").orElseThrow();
57
57
assertEquals("project", found.name);
58
- assertEquals(owner.id, found.owner.id);
58
+ assertEquals(owner.id, found.ownerUser.id);
59
59
assertEquals(Repository.Visibility.PUBLIC, found.visibility);
60
60
}
61
61
MODIFY
src/test/java/de/workaround/model/FederationEntityPersistenceTest.java
+1 -1
@@ -80,7 +80,7 @@
80
80
81
81
Repository repo = new Repository();
82
82
repo.name = "lib";
83
- repo.owner = owner;
83
+ repo.ownerUser = owner;
84
84
repo.visibility = Repository.Visibility.PUBLIC;
85
85
repo.persist();
86
86
MODIFY
src/test/java/de/workaround/ssh/SshGitAccessTest.java
+1 -1
@@ -195,7 +195,7 @@
195
195
@Transactional
196
196
void addCollaborator(Repository repo, User user)
197
197
{
198
- collaboratorService.add(repo.owner, repo, user.username);
198
+ collaboratorService.add(repo.ownerUser, repo, user.username);
199
199
}
200
200
201
201
@Test
MODIFY
src/test/java/de/workaround/web/CollaboratorSettingsTest.java
+1 -1
@@ -256,7 +256,7 @@
256
256
@Transactional
257
257
void addCollaborator(Repository repo, User member)
258
258
{
259
- collaboratorService.add(repo.owner, repo, member.username);
259
+ collaboratorService.add(repo.ownerUser, repo, member.username);
260
260
}
261
261
262
262
@Transactional
ADD
src/test/java/de/workaround/web/OrganisationUiTest.java
+329 -0
@@ -0,0 +1,329 @@
1
+package de.workaround.web;
2
+
3
+import java.util.UUID;
4
+
5
+import org.junit.jupiter.api.Test;
6
+
7
+import de.workaround.account.OrganisationService;
8
+import de.workaround.git.GitRepositoryService;
9
+import de.workaround.model.Organisation;
10
+import de.workaround.model.OrganisationMember;
11
+import de.workaround.model.Repository;
12
+import de.workaround.model.User;
13
+import io.quarkus.test.junit.QuarkusTest;
14
+import io.quarkus.test.security.TestSecurity;
15
+import jakarta.inject.Inject;
16
+import jakarta.transaction.Transactional;
17
+
18
+import static io.restassured.RestAssured.given;
19
+import static org.hamcrest.CoreMatchers.containsString;
20
+import static org.hamcrest.CoreMatchers.not;
21
+import static org.junit.jupiter.api.Assertions.assertEquals;
22
+import static org.junit.jupiter.api.Assertions.assertTrue;
23
+
24
+/**
25
+ * Organisation pages: creation with collision-checked names, the public org page with a
26
+ * visibility-filtered repo list, owner-only member management, and the org owner selector in the
27
+ * new-repository flow.
28
+ */
29
+@QuarkusTest
30
+class OrganisationUiTest
31
+{
32
+ private static final String CREATOR = "orgui-creator";
33
+
34
+ private static final String MEMBER = "orgui-member";
35
+
36
+ private static final String GUEST = "orgui-guest";
37
+
38
+ private static final String STRANGER = "orgui-stranger";
39
+
40
+ @Inject
41
+ OrganisationService organisations;
42
+
43
+ @Inject
44
+ GitRepositoryService service;
45
+
46
+ @Inject
47
+ User.Repo users;
48
+
49
+ @Test
50
+ @TestSecurity(user = CREATOR)
51
+ void createsOrganisationAndSeesItsPage()
52
+ {
53
+ persistUser(CREATOR);
54
+ String name = uniqueName();
55
+
56
+ given()
57
+ .redirects().follow(false)
58
+ .formParam("name", name)
59
+ .formParam("displayName", "ACME Inc.")
60
+ .when().post("/orgs")
61
+ .then().statusCode(303);
62
+
63
+ given()
64
+ .when().get("/orgs/" + name)
65
+ .then().statusCode(200)
66
+ .body(containsString(name))
67
+ .body(containsString("ACME Inc."));
68
+ }
69
+
70
+ @Test
71
+ @TestSecurity(user = CREATOR)
72
+ void creatingOrganisationWithTakenUsernameShowsError()
73
+ {
74
+ persistUser(CREATOR);
75
+
76
+ given()
77
+ .redirects().follow(false)
78
+ .formParam("name", CREATOR)
79
+ .when().post("/orgs")
80
+ .then().statusCode(400)
81
+ .body(containsString("already taken"));
82
+ }
83
+
84
+ @Test
85
+ void unknownOrganisationIs404()
86
+ {
87
+ given()
88
+ .when().get("/orgs/no-such-org")
89
+ .then().statusCode(404);
90
+ }
91
+
92
+ @Test
93
+ @TestSecurity(user = STRANGER)
94
+ void orgPageFiltersRepositoriesByVisibility()
95
+ {
96
+ persistUser(STRANGER);
97
+ User creator = persistUser(CREATOR);
98
+ Organisation org = createOrg(creator);
99
+ createOrgRepo(org, "org-public", Repository.Visibility.PUBLIC);
100
+ createOrgRepo(org, "org-secret", Repository.Visibility.PRIVATE);
101
+
102
+ given()
103
+ .when().get("/orgs/" + org.name)
104
+ .then().statusCode(200)
105
+ .body(containsString("org-public"))
106
+ .body(not(containsString("org-secret")));
107
+ }
108
+
109
+ @Test
110
+ @TestSecurity(user = GUEST)
111
+ void guestSeesPrivateOrgRepositoryOnOrgPageAndInUi()
112
+ {
113
+ User creator = persistUser(CREATOR);
114
+ User guest = persistUser(GUEST);
115
+ Organisation org = createOrg(creator);
116
+ addMember(creator, org, guest, OrganisationMember.Role.GUEST);
117
+ Repository repo = createOrgRepo(org, "guest-visible", Repository.Visibility.PRIVATE);
118
+
119
+ given()
120
+ .when().get("/orgs/" + org.name)
121
+ .then().statusCode(200)
122
+ .body(containsString("guest-visible"));
123
+
124
+ given()
125
+ .when().get("/repos/" + org.name + "/" + repo.name)
126
+ .then().statusCode(200);
127
+ }
128
+
129
+ @Test
130
+ @TestSecurity(user = STRANGER)
131
+ void strangerCannotOpenPrivateOrgRepository()
132
+ {
133
+ persistUser(STRANGER);
134
+ User creator = persistUser(CREATOR);
135
+ Organisation org = createOrg(creator);
136
+ Repository repo = createOrgRepo(org, "org-hidden", Repository.Visibility.PRIVATE);
137
+
138
+ given()
139
+ .when().get("/repos/" + org.name + "/" + repo.name)
140
+ .then().statusCode(404);
141
+ }
142
+
143
+ @Test
144
+ @TestSecurity(user = CREATOR)
145
+ void ownerManagesMembers()
146
+ {
147
+ User creator = persistUser(CREATOR);
148
+ persistUser(MEMBER);
149
+ Organisation org = createOrg(creator);
150
+
151
+ given()
152
+ .redirects().follow(false)
153
+ .formParam("username", MEMBER)
154
+ .formParam("role", "MEMBER")
155
+ .when().post("/orgs/" + org.name + "/members")
156
+ .then().statusCode(303);
157
+
158
+ given()
159
+ .when().get("/orgs/" + org.name + "/members")
160
+ .then().statusCode(200)
161
+ .body(containsString(MEMBER));
162
+
163
+ given()
164
+ .redirects().follow(false)
165
+ .formParam("role", "OWNER")
166
+ .when().post("/orgs/" + org.name + "/members/" + MEMBER + "/role")
167
+ .then().statusCode(303);
168
+
169
+ given()
170
+ .redirects().follow(false)
171
+ .when().post("/orgs/" + org.name + "/members/" + MEMBER + "/remove")
172
+ .then().statusCode(303);
173
+
174
+ assertEquals(1, memberCount(org));
175
+ }
176
+
177
+ @Test
178
+ @TestSecurity(user = CREATOR)
179
+ void addingUnknownMemberShowsError()
180
+ {
181
+ User creator = persistUser(CREATOR);
182
+ Organisation org = createOrg(creator);
183
+
184
+ given()
185
+ .redirects().follow(false)
186
+ .formParam("username", "nobody-here")
187
+ .formParam("role", "MEMBER")
188
+ .when().post("/orgs/" + org.name + "/members")
189
+ .then().statusCode(400)
190
+ .body(containsString("No user with that username exists."));
191
+ }
192
+
193
+ @Test
194
+ @TestSecurity(user = CREATOR)
195
+ void lastOwnerCannotBeRemovedViaUi()
196
+ {
197
+ User creator = persistUser(CREATOR);
198
+ Organisation org = createOrg(creator);
199
+
200
+ given()
201
+ .redirects().follow(false)
202
+ .when().post("/orgs/" + org.name + "/members/" + CREATOR + "/remove")
203
+ .then().statusCode(400)
204
+ .body(containsString("last owner"));
205
+ }
206
+
207
+ @Test
208
+ @TestSecurity(user = MEMBER)
209
+ void memberCannotOpenMembersPage()
210
+ {
211
+ User creator = persistUser(CREATOR);
212
+ User member = persistUser(MEMBER);
213
+ Organisation org = createOrg(creator);
214
+ addMember(creator, org, member, OrganisationMember.Role.MEMBER);
215
+
216
+ given()
217
+ .when().get("/orgs/" + org.name + "/members")
218
+ .then().statusCode(403);
219
+ }
220
+
221
+ @Test
222
+ @TestSecurity(user = CREATOR)
223
+ void newRepositoryFormOffersOrgOwnerAndCreatesOrgRepository()
224
+ {
225
+ User creator = persistUser(CREATOR);
226
+ Organisation org = createOrg(creator);
227
+
228
+ given()
229
+ .when().get("/repos/new")
230
+ .then().statusCode(200)
231
+ .body(containsString(org.name));
232
+
233
+ String repoName = "via-form-" + UUID.randomUUID().toString().substring(0, 8);
234
+ given()
235
+ .redirects().follow(false)
236
+ .formParam("name", repoName)
237
+ .formParam("visibility", "PUBLIC")
238
+ .formParam("owner", org.name)
239
+ .when().post("/repos")
240
+ .then().statusCode(303)
241
+ .header("Location", containsString("/repos/" + org.name + "/" + repoName));
242
+
243
+ assertTrue(findRepo(org, repoName));
244
+ }
245
+
246
+ @Test
247
+ @TestSecurity(user = MEMBER)
248
+ void nonOwnerCannotCreateOrgRepository()
249
+ {
250
+ User creator = persistUser(CREATOR);
251
+ User member = persistUser(MEMBER);
252
+ Organisation org = createOrg(creator);
253
+ addMember(creator, org, member, OrganisationMember.Role.MEMBER);
254
+
255
+ given()
256
+ .redirects().follow(false)
257
+ .formParam("name", "sneaky")
258
+ .formParam("visibility", "PUBLIC")
259
+ .formParam("owner", org.name)
260
+ .when().post("/repos")
261
+ .then().statusCode(403);
262
+ }
263
+
264
+ @Test
265
+ @TestSecurity(user = MEMBER)
266
+ void orgRepositoryAppearsOnMemberDashboard()
267
+ {
268
+ User creator = persistUser(CREATOR);
269
+ User member = persistUser(MEMBER);
270
+ Organisation org = createOrg(creator);
271
+ addMember(creator, org, member, OrganisationMember.Role.MEMBER);
272
+ Repository repo = createOrgRepo(org, "dash-visible", Repository.Visibility.PRIVATE);
273
+
274
+ given()
275
+ .when().get("/")
276
+ .then().statusCode(200)
277
+ .body(containsString(repo.name));
278
+ }
279
+
280
+ private static String uniqueName()
281
+ {
282
+ return "orgui-" + UUID.randomUUID().toString().substring(0, 8);
283
+ }
284
+
285
+ @Transactional
286
+ Organisation createOrg(User creator)
287
+ {
288
+ return organisations.create(creator, uniqueName(), null);
289
+ }
290
+
291
+ Repository createOrgRepo(Organisation org, String prefix, Repository.Visibility visibility)
292
+ {
293
+ return service.create(org, prefix + "-" + UUID.randomUUID().toString().substring(0, 8), visibility, null);
294
+ }
295
+
296
+ @Transactional
297
+ void addMember(User actor, Organisation org, User user, OrganisationMember.Role role)
298
+ {
299
+ organisations.addMember(actor, org, user.username, role);
300
+ }
301
+
302
+ @Transactional
303
+ long memberCount(Organisation org)
304
+ {
305
+ return organisations.members(org).size();
306
+ }
307
+
308
+ @Transactional
309
+ boolean findRepo(Organisation org, String name)
310
+ {
311
+ return service.find(org.name, name).isPresent();
312
+ }
313
+
314
+ @Transactional
315
+ User persistUser(String name)
316
+ {
317
+ User existing = users.findByOidcSubOptional(name).orElse(null);
318
+ if (existing != null)
319
+ {
320
+ return existing;
321
+ }
322
+ User user = new User();
323
+ user.oidcSub = name;
324
+ user.username = name;
325
+ user.persist();
326
+ return user;
327
+ }
328
+
329
+}