gitshark

Clone repository

git clone https://gitshark.de/git/workaround/Gitshark.git
git clone git@gitshark.de:workaround/Gitshark.git

← Commits

✨ (orgs): Add organisations with guest/member/owner roles

60a15ecb53b77c56e033f0284497a7050b21fecc · Michael Hainz · 2026-07-10T06:39:15Z

Changes

68 files changed, +2094 -149

MODIFY README.md +9 -3
diff --git a/README.md b/README.md
index c95a6d1..5e8166e 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/docs/README.md b/docs/README.md
index 2d90fc5..7f555be 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -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
diff --git a/docs/admins/organisations.md b/docs/admins/organisations.md
new file mode 100644
index 0000000..c321a66
--- /dev/null
+++ b/docs/admins/organisations.md
@@ -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
diff --git a/docs/maintainers/forgefed.md b/docs/maintainers/forgefed.md
index 0ef34ed..b9680fc 100644
--- a/docs/maintainers/forgefed.md
+++ b/docs/maintainers/forgefed.md
@@ -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
diff --git a/docs/users/organisations.md b/docs/users/organisations.md
new file mode 100644
index 0000000..016efe2
--- /dev/null
+++ b/docs/users/organisations.md
@@ -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
diff --git a/src/main/java/de/workaround/account/HandleService.java b/src/main/java/de/workaround/account/HandleService.java
new file mode 100644
index 0000000..f9ea95a
--- /dev/null
+++ b/src/main/java/de/workaround/account/HandleService.java
@@ -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
diff --git a/src/main/java/de/workaround/account/InvalidOrganisationException.java b/src/main/java/de/workaround/account/InvalidOrganisationException.java
new file mode 100644
index 0000000..e870374
--- /dev/null
+++ b/src/main/java/de/workaround/account/InvalidOrganisationException.java
@@ -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
diff --git a/src/main/java/de/workaround/account/OrganisationService.java b/src/main/java/de/workaround/account/OrganisationService.java
new file mode 100644
index 0000000..666ef91
--- /dev/null
+++ b/src/main/java/de/workaround/account/OrganisationService.java
@@ -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
diff --git a/src/main/java/de/workaround/account/UsernameService.java b/src/main/java/de/workaround/account/UsernameService.java
index 7490652..5e95d5a 100644
--- a/src/main/java/de/workaround/account/UsernameService.java
+++ b/src/main/java/de/workaround/account/UsernameService.java
@@ -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
diff --git a/src/main/java/de/workaround/api/ApiModels.java b/src/main/java/de/workaround/api/ApiModels.java
index c05d50c..e15805b 100644
--- a/src/main/java/de/workaround/api/ApiModels.java
+++ b/src/main/java/de/workaround/api/ApiModels.java
@@ -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
diff --git a/src/main/java/de/workaround/federation/ActorDocuments.java b/src/main/java/de/workaround/federation/ActorDocuments.java
index 21baa30..11b7a62 100644
--- a/src/main/java/de/workaround/federation/ActorDocuments.java
+++ b/src/main/java/de/workaround/federation/ActorDocuments.java
@@ -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
diff --git a/src/main/java/de/workaround/federation/ActorUris.java b/src/main/java/de/workaround/federation/ActorUris.java
index 9ca53a9..3f99992 100644
--- a/src/main/java/de/workaround/federation/ActorUris.java
+++ b/src/main/java/de/workaround/federation/ActorUris.java
@@ -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
diff --git a/src/main/java/de/workaround/git/AccessPolicy.java b/src/main/java/de/workaround/git/AccessPolicy.java
index 2729f38..77c8b2b 100644
--- a/src/main/java/de/workaround/git/AccessPolicy.java
+++ b/src/main/java/de/workaround/git/AccessPolicy.java
@@ -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
diff --git a/src/main/java/de/workaround/git/CollaboratorService.java b/src/main/java/de/workaround/git/CollaboratorService.java
index 13bb82b..dc82cd1 100644
--- a/src/main/java/de/workaround/git/CollaboratorService.java
+++ b/src/main/java/de/workaround/git/CollaboratorService.java
@@ -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
diff --git a/src/main/java/de/workaround/git/GitRepositoryService.java b/src/main/java/de/workaround/git/GitRepositoryService.java
index 692eeea..04959de 100644
--- a/src/main/java/de/workaround/git/GitRepositoryService.java
+++ b/src/main/java/de/workaround/git/GitRepositoryService.java
@@ -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
diff --git a/src/main/java/de/workaround/http/GitHttpServlet.java b/src/main/java/de/workaround/http/GitHttpServlet.java
index edd5016..0e6a7d4 100644
--- a/src/main/java/de/workaround/http/GitHttpServlet.java
+++ b/src/main/java/de/workaround/http/GitHttpServlet.java
@@ -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
diff --git a/src/main/java/de/workaround/mirror/MirrorService.java b/src/main/java/de/workaround/mirror/MirrorService.java
index 74ccd3c..7a2676b 100644
--- a/src/main/java/de/workaround/mirror/MirrorService.java
+++ b/src/main/java/de/workaround/mirror/MirrorService.java
@@ -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
diff --git a/src/main/java/de/workaround/model/Organisation.java b/src/main/java/de/workaround/model/Organisation.java
new file mode 100644
index 0000000..18a3e32
--- /dev/null
+++ b/src/main/java/de/workaround/model/Organisation.java
@@ -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
diff --git a/src/main/java/de/workaround/model/OrganisationMember.java b/src/main/java/de/workaround/model/OrganisationMember.java
new file mode 100644
index 0000000..6707b80
--- /dev/null
+++ b/src/main/java/de/workaround/model/OrganisationMember.java
@@ -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
diff --git a/src/main/java/de/workaround/model/Repository.java b/src/main/java/de/workaround/model/Repository.java
index 55b408b..8a67cb2 100644
--- a/src/main/java/de/workaround/model/Repository.java
+++ b/src/main/java/de/workaround/model/Repository.java
@@ -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
diff --git a/src/main/java/de/workaround/web/CollaboratorResource.java b/src/main/java/de/workaround/web/CollaboratorResource.java
index 7e087ab..a6d5e74 100644
--- a/src/main/java/de/workaround/web/CollaboratorResource.java
+++ b/src/main/java/de/workaround/web/CollaboratorResource.java
@@ -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
diff --git a/src/main/java/de/workaround/web/HomeResource.java b/src/main/java/de/workaround/web/HomeResource.java
index 63d97ca..2dccf64 100644
--- a/src/main/java/de/workaround/web/HomeResource.java
+++ b/src/main/java/de/workaround/web/HomeResource.java
@@ -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
diff --git a/src/main/java/de/workaround/web/IssueResource.java b/src/main/java/de/workaround/web/IssueResource.java
index 109621e..fd4a082 100644
--- a/src/main/java/de/workaround/web/IssueResource.java
+++ b/src/main/java/de/workaround/web/IssueResource.java
@@ -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
diff --git a/src/main/java/de/workaround/web/MergeRequestResource.java b/src/main/java/de/workaround/web/MergeRequestResource.java
index c78e30f..e111390 100644
--- a/src/main/java/de/workaround/web/MergeRequestResource.java
+++ b/src/main/java/de/workaround/web/MergeRequestResource.java
@@ -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
diff --git a/src/main/java/de/workaround/web/OrganisationResource.java b/src/main/java/de/workaround/web/OrganisationResource.java
new file mode 100644
index 0000000..2e42500
--- /dev/null
+++ b/src/main/java/de/workaround/web/OrganisationResource.java
@@ -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
diff --git a/src/main/java/de/workaround/web/RepoNavService.java b/src/main/java/de/workaround/web/RepoNavService.java
index f41ee6d..0253e50 100644
--- a/src/main/java/de/workaround/web/RepoNavService.java
+++ b/src/main/java/de/workaround/web/RepoNavService.java
@@ -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
diff --git a/src/main/java/de/workaround/web/RepositoryResource.java b/src/main/java/de/workaround/web/RepositoryResource.java
index 6ba09ca..bea2d1b 100644
--- a/src/main/java/de/workaround/web/RepositoryResource.java
+++ b/src/main/java/de/workaround/web/RepositoryResource.java
@@ -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
diff --git a/src/main/resources/db/migration/V14__organisations.sql b/src/main/resources/db/migration/V14__organisations.sql
new file mode 100644
index 0000000..cf5ac0c
--- /dev/null
+++ b/src/main/resources/db/migration/V14__organisations.sql
@@ -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
diff --git a/src/main/resources/templates/CollaboratorResource/collaborators.html b/src/main/resources/templates/CollaboratorResource/collaborators.html
index 265cd4f..ffdbbaa 100644
--- a/src/main/resources/templates/CollaboratorResource/collaborators.html
+++ b/src/main/resources/templates/CollaboratorResource/collaborators.html
@@ -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
diff --git a/src/main/resources/templates/HomeResource/dashboard.html b/src/main/resources/templates/HomeResource/dashboard.html
index 7aa7dc7..1277adb 100644
--- a/src/main/resources/templates/HomeResource/dashboard.html
+++ b/src/main/resources/templates/HomeResource/dashboard.html
@@ -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
diff --git a/src/main/resources/templates/HomeResource/home.html b/src/main/resources/templates/HomeResource/home.html
index a65cc26..babbad3 100644
--- a/src/main/resources/templates/HomeResource/home.html
+++ b/src/main/resources/templates/HomeResource/home.html
@@ -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
diff --git a/src/main/resources/templates/HomeResource/newRepo.html b/src/main/resources/templates/HomeResource/newRepo.html
index b952aa8..c9c22c2 100644
--- a/src/main/resources/templates/HomeResource/newRepo.html
+++ b/src/main/resources/templates/HomeResource/newRepo.html
@@ -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
diff --git a/src/main/resources/templates/IssueResource/editIssue.html b/src/main/resources/templates/IssueResource/editIssue.html
index ed455ed..87b3591 100644
--- a/src/main/resources/templates/IssueResource/editIssue.html
+++ b/src/main/resources/templates/IssueResource/editIssue.html
@@ -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
diff --git a/src/main/resources/templates/IssueResource/issue.html b/src/main/resources/templates/IssueResource/issue.html
index e3d5583..715698c 100644
--- a/src/main/resources/templates/IssueResource/issue.html
+++ b/src/main/resources/templates/IssueResource/issue.html
@@ -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
diff --git a/src/main/resources/templates/IssueResource/issues.html b/src/main/resources/templates/IssueResource/issues.html
index e72c3a0..fb9d1c8 100644
--- a/src/main/resources/templates/IssueResource/issues.html
+++ b/src/main/resources/templates/IssueResource/issues.html
@@ -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
diff --git a/src/main/resources/templates/IssueResource/newIssue.html b/src/main/resources/templates/IssueResource/newIssue.html
index 46e51e6..dcf9406 100644
--- a/src/main/resources/templates/IssueResource/newIssue.html
+++ b/src/main/resources/templates/IssueResource/newIssue.html
@@ -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
diff --git a/src/main/resources/templates/MergeRequestResource/mergeRequest.html b/src/main/resources/templates/MergeRequestResource/mergeRequest.html
index aa2046b..f561783 100644
--- a/src/main/resources/templates/MergeRequestResource/mergeRequest.html
+++ b/src/main/resources/templates/MergeRequestResource/mergeRequest.html
@@ -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">&larr; Merge requests</a></p>
6 +<p><a href="/repos/{repo.ownerHandle}/{repo.name}/merge-requests">&larr; 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
diff --git a/src/main/resources/templates/MergeRequestResource/mergeRequests.html b/src/main/resources/templates/MergeRequestResource/mergeRequests.html
index 07e359d..38a4f18 100644
--- a/src/main/resources/templates/MergeRequestResource/mergeRequests.html
+++ b/src/main/resources/templates/MergeRequestResource/mergeRequests.html
@@ -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
diff --git a/src/main/resources/templates/MergeRequestResource/newMergeRequest.html b/src/main/resources/templates/MergeRequestResource/newMergeRequest.html
index eae98e3..65f3189 100644
--- a/src/main/resources/templates/MergeRequestResource/newMergeRequest.html
+++ b/src/main/resources/templates/MergeRequestResource/newMergeRequest.html
@@ -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">&larr; Merge requests</a></p>
6 + <p><a href="/repos/{repo.ownerHandle}/{repo.name}/merge-requests">&larr; 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
diff --git a/src/main/resources/templates/OrganisationResource/members.html b/src/main/resources/templates/OrganisationResource/members.html
new file mode 100644
index 0000000..5ffc157
--- /dev/null
+++ b/src/main/resources/templates/OrganisationResource/members.html
@@ -0,0 +1,48 @@
1 +{#include layout}
2 +{#title}Members · {org.name} – git-shark{/title}
3 +<p><a href="/orgs/{org.name}">&larr; {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
diff --git a/src/main/resources/templates/OrganisationResource/newOrg.html b/src/main/resources/templates/OrganisationResource/newOrg.html
new file mode 100644
index 0000000..2e05e98
--- /dev/null
+++ b/src/main/resources/templates/OrganisationResource/newOrg.html
@@ -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
diff --git a/src/main/resources/templates/OrganisationResource/org.html b/src/main/resources/templates/OrganisationResource/org.html
new file mode 100644
index 0000000..0df46e8
--- /dev/null
+++ b/src/main/resources/templates/OrganisationResource/org.html
@@ -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
diff --git a/src/main/resources/templates/RepositoryResource/blob.html b/src/main/resources/templates/RepositoryResource/blob.html
index 19cf7f6..e04f7ae 100644
--- a/src/main/resources/templates/RepositoryResource/blob.html
+++ b/src/main/resources/templates/RepositoryResource/blob.html
@@ -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
diff --git a/src/main/resources/templates/RepositoryResource/branches.html b/src/main/resources/templates/RepositoryResource/branches.html
index a6522ab..2915329 100644
--- a/src/main/resources/templates/RepositoryResource/branches.html
+++ b/src/main/resources/templates/RepositoryResource/branches.html
@@ -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
diff --git a/src/main/resources/templates/RepositoryResource/commits.html b/src/main/resources/templates/RepositoryResource/commits.html
index a3db51c..313b867 100644
--- a/src/main/resources/templates/RepositoryResource/commits.html
+++ b/src/main/resources/templates/RepositoryResource/commits.html
@@ -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
diff --git a/src/main/resources/templates/RepositoryResource/overview.html b/src/main/resources/templates/RepositoryResource/overview.html
index ffa5357..5fcf195 100644
--- a/src/main/resources/templates/RepositoryResource/overview.html
+++ b/src/main/resources/templates/RepositoryResource/overview.html
@@ -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
diff --git a/src/main/resources/templates/RepositoryResource/settings.html b/src/main/resources/templates/RepositoryResource/settings.html
index b274845..55fde4e 100644
--- a/src/main/resources/templates/RepositoryResource/settings.html
+++ b/src/main/resources/templates/RepositoryResource/settings.html
@@ -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
diff --git a/src/main/resources/templates/RepositoryResource/sidebar.html b/src/main/resources/templates/RepositoryResource/sidebar.html
index 3b24bec..4d0dec0 100644
--- a/src/main/resources/templates/RepositoryResource/sidebar.html
+++ b/src/main/resources/templates/RepositoryResource/sidebar.html
@@ -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
diff --git a/src/main/resources/templates/RepositoryResource/tags.html b/src/main/resources/templates/RepositoryResource/tags.html
index 0b8f762..a832c72 100644
--- a/src/main/resources/templates/RepositoryResource/tags.html
+++ b/src/main/resources/templates/RepositoryResource/tags.html
@@ -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
diff --git a/src/main/resources/templates/RepositoryResource/tree.html b/src/main/resources/templates/RepositoryResource/tree.html
index 29f0528..b8dabf0 100644
--- a/src/main/resources/templates/RepositoryResource/tree.html
+++ b/src/main/resources/templates/RepositoryResource/tree.html
@@ -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
diff --git a/src/main/resources/templates/tags/repoAvatar.html b/src/main/resources/templates/tags/repoAvatar.html
index 21ada92..e6b6787 100644
--- a/src/main/resources/templates/tags/repoAvatar.html
+++ b/src/main/resources/templates/tags/repoAvatar.html
@@ -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
diff --git a/src/test/java/de/workaround/account/OrganisationServiceTest.java b/src/test/java/de/workaround/account/OrganisationServiceTest.java
new file mode 100644
index 0000000..8225ed8
--- /dev/null
+++ b/src/test/java/de/workaround/account/OrganisationServiceTest.java
@@ -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
diff --git a/src/test/java/de/workaround/federation/FederationFollowingTest.java b/src/test/java/de/workaround/federation/FederationFollowingTest.java
index 33bb725..5257f91 100644
--- a/src/test/java/de/workaround/federation/FederationFollowingTest.java
+++ b/src/test/java/de/workaround/federation/FederationFollowingTest.java
@@ -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
diff --git a/src/test/java/de/workaround/federation/FederationHandshakeTest.java b/src/test/java/de/workaround/federation/FederationHandshakeTest.java
index 42442c0..c17a658 100644
--- a/src/test/java/de/workaround/federation/FederationHandshakeTest.java
+++ b/src/test/java/de/workaround/federation/FederationHandshakeTest.java
@@ -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
diff --git a/src/test/java/de/workaround/federation/FederationPushTest.java b/src/test/java/de/workaround/federation/FederationPushTest.java
index 14e61ab..5ea9e19 100644
--- a/src/test/java/de/workaround/federation/FederationPushTest.java
+++ b/src/test/java/de/workaround/federation/FederationPushTest.java
@@ -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
diff --git a/src/test/java/de/workaround/git/AccessPolicyTest.java b/src/test/java/de/workaround/git/AccessPolicyTest.java
index 4c39df2..c67ceb0 100644
--- a/src/test/java/de/workaround/git/AccessPolicyTest.java
+++ b/src/test/java/de/workaround/git/AccessPolicyTest.java
@@ -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
diff --git a/src/test/java/de/workaround/git/IssueServiceTest.java b/src/test/java/de/workaround/git/IssueServiceTest.java
index 1bfb5ba..0458c4a 100644
--- a/src/test/java/de/workaround/git/IssueServiceTest.java
+++ b/src/test/java/de/workaround/git/IssueServiceTest.java
@@ -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
diff --git a/src/test/java/de/workaround/git/OrgRepositoryTest.java b/src/test/java/de/workaround/git/OrgRepositoryTest.java
new file mode 100644
index 0000000..5e7dcf5
--- /dev/null
+++ b/src/test/java/de/workaround/git/OrgRepositoryTest.java
@@ -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
diff --git a/src/test/java/de/workaround/git/RepositoryPinServiceTest.java b/src/test/java/de/workaround/git/RepositoryPinServiceTest.java
index bd07d74..9108992 100644
--- a/src/test/java/de/workaround/git/RepositoryPinServiceTest.java
+++ b/src/test/java/de/workaround/git/RepositoryPinServiceTest.java
@@ -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
diff --git a/src/test/java/de/workaround/http/GitSmartHttpTest.java b/src/test/java/de/workaround/http/GitSmartHttpTest.java
index 5415b01..43c3c2b 100644
--- a/src/test/java/de/workaround/http/GitSmartHttpTest.java
+++ b/src/test/java/de/workaround/http/GitSmartHttpTest.java
@@ -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
diff --git a/src/test/java/de/workaround/http/OrgGitHttpTest.java b/src/test/java/de/workaround/http/OrgGitHttpTest.java
new file mode 100644
index 0000000..e81c245
--- /dev/null
+++ b/src/test/java/de/workaround/http/OrgGitHttpTest.java
@@ -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
diff --git a/src/test/java/de/workaround/mirror/MirrorPushTest.java b/src/test/java/de/workaround/mirror/MirrorPushTest.java
index 9c669b7..7feff78 100644
--- a/src/test/java/de/workaround/mirror/MirrorPushTest.java
+++ b/src/test/java/de/workaround/mirror/MirrorPushTest.java
@@ -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
diff --git a/src/test/java/de/workaround/mirror/MirrorSettingsTest.java b/src/test/java/de/workaround/mirror/MirrorSettingsTest.java
index e0466f7..cb1941c 100644
--- a/src/test/java/de/workaround/mirror/MirrorSettingsTest.java
+++ b/src/test/java/de/workaround/mirror/MirrorSettingsTest.java
@@ -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
diff --git a/src/test/java/de/workaround/model/EntityPersistenceTest.java b/src/test/java/de/workaround/model/EntityPersistenceTest.java
index a86b0b6..0b5fed3 100644
--- a/src/test/java/de/workaround/model/EntityPersistenceTest.java
+++ b/src/test/java/de/workaround/model/EntityPersistenceTest.java
@@ -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
diff --git a/src/test/java/de/workaround/model/FederationEntityPersistenceTest.java b/src/test/java/de/workaround/model/FederationEntityPersistenceTest.java
index ecc8674..699956b 100644
--- a/src/test/java/de/workaround/model/FederationEntityPersistenceTest.java
+++ b/src/test/java/de/workaround/model/FederationEntityPersistenceTest.java
@@ -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
diff --git a/src/test/java/de/workaround/ssh/SshGitAccessTest.java b/src/test/java/de/workaround/ssh/SshGitAccessTest.java
index 30bb795..d0969eb 100644
--- a/src/test/java/de/workaround/ssh/SshGitAccessTest.java
+++ b/src/test/java/de/workaround/ssh/SshGitAccessTest.java
@@ -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
diff --git a/src/test/java/de/workaround/web/CollaboratorSettingsTest.java b/src/test/java/de/workaround/web/CollaboratorSettingsTest.java
index c8891cb..5c80497 100644
--- a/src/test/java/de/workaround/web/CollaboratorSettingsTest.java
+++ b/src/test/java/de/workaround/web/CollaboratorSettingsTest.java
@@ -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
diff --git a/src/test/java/de/workaround/web/OrganisationUiTest.java b/src/test/java/de/workaround/web/OrganisationUiTest.java
new file mode 100644
index 0000000..a7eb905
--- /dev/null
+++ b/src/test/java/de/workaround/web/OrganisationUiTest.java
@@ -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 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog