name: penpot-admin description: Use when accessing or managing Penpot admin (team-level role), bootstrapping the first team owner, or wiring admin-related tests for Penpot in a foss-server-bundle-devstack. Penpot has NO global admin role — admin is per-team only, surfaced inside the normal app UI.
penpot-admin — team-level admin access
Penpot has no global admin / superuser role. "Admin" in Penpot always means admin of a specific team. There is no /admin URL, no admin app, no is_superuser column on the profile.
The team-level roles, on team_profile_rel:
| Role | Capabilities |
|---|---|
is_owner = true |
Full team control. Exactly one owner per team. Can transfer ownership. |
is_admin = true |
Manage members, invite, change roles, settings |
can_edit = true |
Create/edit team content |
| (none) | Viewer — read-only access |
Defined in backend/src/app/rpc/commands/teams.clj and backend/src/app/types/team.cljc. Checked across the frontend via (:is-admin permissions) (see frontend/src/app/main/ui/dashboard/team.cljs:281,330,800,1286).
UI access
Admin functions live inside the dashboard. Penpot uses query-string routing, not path-style — the team-id is a query parameter, not a path segment:
https://design.${PLATFORM_DOMAIN}/#/dashboard/members?team-id=<team-uuid>
https://design.${PLATFORM_DOMAIN}/#/dashboard/invitations?team-id=<team-uuid>
https://design.${PLATFORM_DOMAIN}/#/dashboard/webhooks?team-id=<team-uuid>
https://design.${PLATFORM_DOMAIN}/#/dashboard/settings?team-id=<team-uuid>
A path-style URL like .../dashboard/team/<uuid>/members (which other apps use) will fail with "Internal Error" because the team-id segment doesn't parse as a UUID.
Admin sub-pages are NOT in the left sidebar. The sidebar only lists content sections (Projects, Drafts, Libraries, Fonts). To reach admin pages from the UI:
- Click the team name at the top of the left sidebar (or the chevron
▾next to it). - A context menu opens with the admin entries: Members / Invitations / Webhooks / Settings.
- Those four items only appear if your role is admin or owner — non-admins get a shorter menu.
The role-change dropdown surfaces on the Members page next to each member row, gated on (or is-owner is-admin) (backend/src/app/rpc/commands/teams.clj:732). You won't see a dropdown next to your own name (Penpot hides self-demote).
First-admin bootstrap
Penpot uses first-team-creator-becomes-owner:
- New SSO user lands.
backend/src/app/http/auth_request.clj(thewrap-authzForwardAuth middleware at line 79) auto-registers them as aprofilerow viaauth/create-profile, gated on theenable-x-auth-request-auto-registerflag being present inPENPOT_FLAGS(parsed as:x-auth-request-auto-registerand checked atauth_request.clj:62). create-profile-rels(inbackend/src/app/rpc/commands/auth.clj) callsteams/create-teamwith the new profile as the owner, producing a "Default" team where this user is the soleis_owner = truemember (backend/src/app/rpc/commands/teams.clj:548—create-team-role).- That user can now invite others into their default team and assign roles.
Two consequences:
- Every SSO user gets their own private team on first login. They are owner of that team, no one else's.
- If you want a shared team that admins can join, an existing team owner must invite by email through the UI — or you seed it via DB.
There is no "first user across the whole instance is global admin" path. There is no global admin to be.
Promoting another user to team admin
HTTP RPC (the API call the UI makes):
POST https://design.${PLATFORM_DOMAIN}/api/rpc/command/update-team-member-role
Cookie: <penpot session, established after SSO landing>
Content-Type: application/transit+json
{:team-id "<uuid>"
:member-id "<uuid>"
:role :admin} ; or :editor :viewer :owner
Source: backend/src/app/rpc/commands/teams.clj:714-777. Gated on the caller being team owner or admin (teams.clj:732-734). Owner transfers are restricted: cant-change-role-to-owner (raised at teams.clj:737-739 when trying to demote an existing owner) and cant-promote-to-owner (raised at teams.clj:742-744 when a non-owner tries to promote someone to owner).
UI equivalent: Team Settings → Members → member's role dropdown → Admin.
Direct DB promotion (cross-team override)
If you need to make someone admin of a team they didn't create, and no existing admin is available to do it:
-- inside the postgres container, penpot DB
UPDATE team_profile_rel
SET is_admin = true, can_edit = true
WHERE profile_id = (SELECT id FROM profile WHERE email = '<email>')
AND team_id = (SELECT id FROM team WHERE name = '<team-name>');
To promote to owner (singular per team), also clear the prior owner:
UPDATE team_profile_rel SET is_owner = false WHERE team_id = '<uuid>';
UPDATE team_profile_rel SET is_owner = true, is_admin = true, can_edit = true
WHERE profile_id = '<new-owner-uuid>' AND team_id = '<uuid>';
PREPL — backend admin socket
The bundle's PENPOT_FLAGS enables enable-prepl-server → Penpot exposes a Clojure PREPL socket at penpot-backend:6063 (backend/src/app/main.clj:454). Commands available (backend/src/app/srepl/cli.clj):
create-profile— create a profile with email + passwordupdate-profile— change fullname/email/password/is-activedelete-profile— soft or hard deletesearch-profile— find by email
No role-change command. PREPL is for profile lifecycle and password reset only.
From inside the bundle:
docker compose exec penpot-backend bash -lc \
'echo "{:cmd \"search-profile\" :params {:email \"you@example.com\"}}" | nc localhost 6063'
E2E test fixtures
Bypass the email-invitation flow with a direct DB INSERT (verified working)
Most SSO Penpot deployments don't have outbound SMTP configured, so the UI's "Invite Members" button creates a team_invitation row that never reaches anyone. For e2e you can skip the invitation entirely and INSERT the membership directly. The invitee's profile row must already exist — they need to have completed at least one SSO login. If they haven't, either log them in once first or the INSERT will fail on the foreign key.
-- 1. Verify the invitee profile exists (must return 1 row)
SELECT id, email, fullname FROM profile WHERE email = '<invitee-email>';
-- 2. INSERT them onto the target team. Idempotent via ON CONFLICT.
INSERT INTO team_profile_rel (team_id, profile_id, is_owner, is_admin, can_edit, created_at, modified_at)
SELECT '<team-uuid>',
(SELECT id FROM profile WHERE email = '<invitee-email>'),
false, false, true, -- Editor (set is_admin=true for Admin)
now(), now()
ON CONFLICT (team_id, profile_id) DO NOTHING
RETURNING team_id, profile_id, is_admin, can_edit;
-- 3. Verify the team now has both members
SELECT p.email, p.fullname, tpr.is_owner, tpr.is_admin, tpr.can_edit
FROM team_profile_rel tpr
JOIN profile p ON p.id = tpr.profile_id
WHERE tpr.team_id = '<team-uuid>'
ORDER BY tpr.is_owner DESC;
The unique constraint on team_profile_rel is (team_id, profile_id) — ON CONFLICT (team_id, profile_id) DO NOTHING is the right idempotent shape.
If you ALSO want to clear the pending team_invitation row that the UI may have created earlier (cosmetic — leaving it doesn't break anything), DELETE FROM team_invitation WHERE team_id = '<team-uuid>' AND email_to = '<invitee-email>'. Skip this if your test asserts the invitation lifecycle separately.
Standard test patterns
- UI behaviour test (e.g. role dropdown only renders for admins): drive Playwright/Cypress against the dashboard URL above, assert dropdown visibility per role.
- Multi-user flow setup: do SSO landing once to grab the session cookie, then drive
update-team-member-roleRPC for role state; assert via UI navigation. - Negative auth tests: DB-seed two profiles with known roles, hit the RPC as the wrong user, assert
:insufficient-permissions.
Diagnostic — what email is the current browser session authenticated as?
In DevTools console:
JSON.parse(localStorage.getItem("penpot")).profile.email
Useful when verifying the SSO chain delivered the right identity (e.g. when the sidebar profile-fullname shows a UUID because the IdP didn't send a name claim). The DOM class for the sidebar profile button is main_ui_dashboard_sidebar__profile-fullname.
Common gotchas
- No global admin. Don't write tests assuming an "admin user can do X across all teams" — admin scope is always per-team in Penpot.
- Owner ≠ admin. Owner is a superset:
is_ownerimplies admin capabilities.cant-change-role-to-owner(teams.clj:737-739) andcant-promote-to-owner(teams.clj:742-744) are explicit business rules — only the existing owner can transfer ownership. - Each SSO user starts isolated. The auto-created Default team has only them in it. They will not see your "main" team unless invited explicitly.
- The fork's ForwardAuth implementation auto-registers — see
penpot-security.mdfor the audit. The:x-auth-request-auto-registerflag is what enables this.
Requirements
The following requirements pin the per-app admin contract for Penpot.
Each is verified by a test in tests/apps/penpot-admin.spec.ts, linked
via a // @spec penpot-admin#<requirement-slug> tag.
Requirement: admin team URLs SHALL NOT bypass the SSO chain
Cold contexts (no SSO cookie) hitting any admin team URL
(/#/dashboard/{members,invitations,webhooks,settings}?team-id=...)
MUST be redirected to the IDP / auth wall. Penpot's team admin
pages are not in the ForwardAuth bypass list.
Scenario: Cold visit to a team admin URL bounces to auth
- GIVEN a fresh browser context with no
_oauth2_proxycookie - WHEN the context navigates to
https://design.${PLATFORM_DOMAIN}/#/dashboard/members?team-id=<team-uuid> - THEN the response chain ends at an
isAuthWallhost - AND the Penpot dashboard does NOT render the members table
Requirement: non-admin SHALL NOT see Invite controls
An SSO-authenticated user with team role Editor (or below) on a
team they navigate to MUST NOT see the "Invite people" button on
/#/dashboard/invitations?team-id=<team>. The user lands on the
host (not bounced to the IDP) and the page renders, but the
admin-only invite control is absent.
Scenario: Editor sees Invite-page chrome but no Invite button
- GIVEN an SSO-authenticated user with
team_profile_rel.is_owner = falseANDis_admin = falseon the target team - WHEN the user navigates to
/#/dashboard/invitations?team-id=<team-uuid> - THEN the page stays on the Penpot host (no IDP bounce)
- AND a
getByRole("button", { name: /invite people/i })locator resolves to zero matches - AND the same user's profile page (
/#/settings/profile) renders normally — distinguishing "admin-only control hidden" from "auth chain broken"
Requirement: team Owner SHALL see Invite + role combobox
A user with team_profile_rel.is_owner = true for a given team
MUST see the "Invite people" button on
/#/dashboard/invitations?team-id=<team> AND a role combobox
next to other members' rows on /#/dashboard/members?team-id=<team>.
This pins both gating signals (server-side check returns
admin-level controls, UI renders them).
Penpot deliberately hides the self-row role dropdown — the Owner sees the combobox next to OTHER members, not themselves.
Scenario: Owner sees Invite button + role combobox on other members
- GIVEN an SSO-authenticated user with
team_profile_rel.is_owner = trueon the target team - AND the team has at least one other member besides the Owner
- WHEN the Owner navigates to
/#/dashboard/invitations?team-id=<team-uuid> - THEN the "Invite people" button is visible
- AND when the Owner navigates to
/#/dashboard/members?team-id=<team-uuid> - THEN a role-change combobox is visible next to at least one other-member row
- AND no role-change combobox is visible next to the Owner's own row (Penpot's deliberate self-row protection)
References
backend/src/app/http/auth_request.clj:79—wrap-authzForwardAuth middleware (auto-register at line 62 when:x-auth-request-auto-registerflag is set;valid-email?at 37,resolve-emailat 41)backend/src/app/rpc/commands/teams.clj:714-777—update-team-member-rolebackend/src/app/rpc/commands/teams.clj:548—create-team-role(used for initial owner)backend/src/app/rpc/commands/auth.clj—create-profile,create-profile-relsbackend/src/app/srepl/cli.clj— PREPL admin commands (exec-commandmultimethod;create-profileat line 51,update-profileat 67,delete-profileat 93,search-profileat 111)backend/src/app/main.clj:453-455— PREPL server binding (:prepl-port, default6063)frontend/src/app/main/ui/dashboard/team.cljs— UI role display gates: lines 117, 133 (invitations/members panels), 281 (is-adminin team-members), 330 (admin?in team-settings), 800 (admin?in invitations), 1286 (members-row visibility)- Bundle compose:
PENPOT_FLAGSline infoss-server-bundle-devstack/docker-compose.yml(must includeenable-x-auth-request-headers enable-x-auth-request-auto-register)