diff --git a/.claude/skills/ui-walkthrough/report-template.html b/.claude/skills/ui-walkthrough/report-template.html
index dab62e6711..3e96216a3b 100644
--- a/.claude/skills/ui-walkthrough/report-template.html
+++ b/.claude/skills/ui-walkthrough/report-template.html
@@ -174,12 +174,7 @@ window.__WALKTHROUGH__ = /*__DATA__*/{"feature":"No data","branch":"","generated
$("lbl-dark").classList.toggle("on", state.theme === "dark");
}
- // Resolve through D.assets when present, so a self-contained report can inline each image
- // once as a data URI instead of repeating it in both the stage and the thumbnail rail.
- function srcFor(v) {
- var key = state.theme === "dark" ? (v.dark || v.light) : (v.light || v.dark);
- return key ? ((D.assets && D.assets[key]) || key) : "";
- }
+ function srcFor(v) { return state.theme === "dark" ? (v.dark || v.light) : (v.light || v.dark); }
function findingsForView(id) {
var all = (D.findings && D.findings.visual || []).concat(D.findings && D.findings.ux || []);
@@ -234,10 +229,8 @@ window.__WALKTHROUGH__ = /*__DATA__*/{"feature":"No data","branch":"","generated
fs.forEach(function (f) {
var li = document.createElement("li"); li.className = "finding";
li.innerHTML = '' + (f.severity || "note") + '' +
- '' + (f.fixed ? 'FIXED ' : '') +
- '' + (f.title || "") + ' — ' + (f.detail || "") +
- (f.fixed && f.fixNote ? ' Change: ' + f.fixNote + ''
- : f.fix ? ' Fix: ' + f.fix + '' : '') + '';
+ '' + (f.title || "") + ' — ' + (f.detail || "") +
+ (f.fix ? ' Fix: ' + f.fix + '' : '') + '';
notes.appendChild(li);
});
$("prev").disabled = state.i === 0;
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/ProfilePictureControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/ProfilePictureControllerTest.java
index eef2e50092..93dbf3b3f3 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/ProfilePictureControllerTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/ProfilePictureControllerTest.java
@@ -3,11 +3,15 @@ package stirling.software.proprietary.security.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyCollection;
+import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -26,11 +30,14 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.multipart.MultipartFile;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.ProfilePictureService;
+import stirling.software.proprietary.security.service.ProfilePictureService.InvalidProfilePictureException;
import stirling.software.proprietary.security.service.ProfilePictureService.StoredImage;
import stirling.software.proprietary.security.service.UserService;
@@ -151,4 +158,65 @@ class ProfilePictureControllerTest {
.principal(() -> "ghost"))
.andExpect(status().isUnauthorized());
}
+
+ @Test
+ void uploadStoresAgainstTheResolvedUserNotTheRequest() throws Exception {
+ mockMvc.perform(multipart("/api/v1/user/profile-picture").file(png()).principal(VIEWER))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.hasProfilePicture").value(true));
+
+ // The load-bearing assertion: the account written to comes from the principal, so a
+ // request cannot aim the upload at somebody else.
+ ArgumentCaptor stored = ArgumentCaptor.forClass(User.class);
+ verify(profilePictureService).store(stored.capture(), any(MultipartFile.class));
+ assertThat(stored.getValue().getId()).isEqualTo(1L);
+ }
+
+ @Test
+ void aRejectedUploadIsA400CarryingTheReasonRatherThanA500() throws Exception {
+ doThrow(new InvalidProfilePictureException("Image is larger than 5MB"))
+ .when(profilePictureService)
+ .store(any(), any(MultipartFile.class));
+
+ mockMvc.perform(multipart("/api/v1/user/profile-picture").file(png()).principal(VIEWER))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.error").value("invalidImage"))
+ .andExpect(jsonPath("$.message").value("Image is larger than 5MB"));
+ }
+
+ @Test
+ void uploadFromAnUnknownPrincipalStoresNothing() throws Exception {
+ when(userService.findByUsernameIgnoreCase("ghost")).thenReturn(Optional.empty());
+
+ mockMvc.perform(
+ multipart("/api/v1/user/profile-picture")
+ .file(png())
+ .principal(() -> "ghost"))
+ .andExpect(status().isUnauthorized());
+
+ verifyNoInteractions(profilePictureService);
+ }
+
+ @Test
+ void removeDeletesTheSignedInUsersOwnPicture() throws Exception {
+ mockMvc.perform(delete("/api/v1/user/profile-picture").principal(VIEWER))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.hasProfilePicture").value(false));
+
+ verify(profilePictureService).delete(1L);
+ }
+
+ @Test
+ void removeFromAnUnknownPrincipalDeletesNothing() throws Exception {
+ when(userService.findByUsernameIgnoreCase("ghost")).thenReturn(Optional.empty());
+
+ mockMvc.perform(delete("/api/v1/user/profile-picture").principal(() -> "ghost"))
+ .andExpect(status().isUnauthorized());
+
+ verifyNoInteractions(profilePictureService);
+ }
+
+ private static MockMultipartFile png() {
+ return new MockMultipartFile("file", "avatar.png", "image/png", new byte[] {1, 2, 3});
+ }
}
diff --git a/app/saas/src/main/java/stirling/software/saas/controller/SaasTeamController.java b/app/saas/src/main/java/stirling/software/saas/controller/SaasTeamController.java
index 1f78c450a8..9346e18b4b 100644
--- a/app/saas/src/main/java/stirling/software/saas/controller/SaasTeamController.java
+++ b/app/saas/src/main/java/stirling/software/saas/controller/SaasTeamController.java
@@ -475,8 +475,7 @@ public class SaasTeamController {
user.getUsername(),
user.getEmail(),
membership.getRole().name(),
- membership.getAcceptedAt(),
- user.getSupabaseId() == null ? null : user.getSupabaseId().toString());
+ membership.getAcceptedAt());
}
private TeamDetailsDTO toTeamDetailsDTO(Team team, boolean isLeader) {
@@ -521,9 +520,6 @@ public class SaasTeamController {
private final String email;
private final String role;
private final LocalDateTime joinedAt;
-
- /** Supabase auth uuid; the avatar lives at {@code /avatar} in profile-pictures. */
- private final String supabaseAuthId;
}
@Data
diff --git a/app/saas/src/main/resources/db/migration/saas/V34__profile_picture_team_visibility.sql b/app/saas/src/main/resources/db/migration/saas/V34__profile_picture_team_visibility.sql
deleted file mode 100644
index fa48deb7ce..0000000000
--- a/app/saas/src/main/resources/db/migration/saas/V34__profile_picture_team_visibility.sql
+++ /dev/null
@@ -1,61 +0,0 @@
--- Let teammates see each other's profile pictures.
---
--- Until now the profile-pictures bucket and its metadata table were readable only by their owner,
--- which is why the SaaS team roster fell back to initials. The policies below widen SELECT (only
--- SELECT) to "people you share a team with", matching what the self-hosted backend enforces in
--- ProfilePictureService.visibleUserIds. Writes stay owner-only: nobody can change anyone else's avatar.
---
--- Idempotent (DROP ... IF EXISTS before CREATE) so re-running is safe.
-
--- Team-mates of the calling user, resolved from the app's own tables. SECURITY DEFINER because the
--- caller cannot read users/team_memberships directly; the function returns only auth uuids, and is
--- restricted to authenticated callers.
-CREATE OR REPLACE FUNCTION public.profile_picture_teammate_ids()
-RETURNS TABLE (supabase_auth_id uuid)
-LANGUAGE sql
-STABLE
-SECURITY DEFINER
-SET search_path = public
-AS $$
- SELECT DISTINCT peer.supabase_auth_id
- FROM users me
- JOIN team_memberships my_membership ON my_membership.user_id = me.user_id
- JOIN team_memberships peer_membership ON peer_membership.team_id = my_membership.team_id
- JOIN users peer ON peer.user_id = peer_membership.user_id
- WHERE me.supabase_auth_id = auth.uid()
- AND peer.supabase_auth_id IS NOT NULL;
-$$;
-
-REVOKE ALL ON FUNCTION public.profile_picture_teammate_ids() FROM PUBLIC;
-GRANT EXECUTE ON FUNCTION public.profile_picture_teammate_ids() TO authenticated;
-
--- Storage: read a teammate's avatar at "/avatar". Compared as text, not cast to
--- uuid, so a stray object whose first path segment isn't a uuid can't error the whole policy.
-DROP POLICY IF EXISTS "Teammates can view profile pictures" ON storage.objects;
-CREATE POLICY "Teammates can view profile pictures"
-ON storage.objects
-FOR SELECT
-TO authenticated
-USING (
- bucket_id = 'profile-pictures'
- AND (storage.foldername(name))[1] IN (
- SELECT supabase_auth_id::text FROM public.profile_picture_teammate_ids()
- )
-);
-
--- Metadata: the account page reads source/provider to know whether a picture is provider-synced.
--- Guarded because the metadata table is created by the Supabase-side avatar migration, which some
--- environments may not have applied yet.
-DO $$
-BEGIN
- IF to_regclass('public.profile_picture_metadata') IS NOT NULL THEN
- DROP POLICY IF EXISTS "Teammates can view profile picture metadata"
- ON public.profile_picture_metadata;
- CREATE POLICY "Teammates can view profile picture metadata"
- ON public.profile_picture_metadata
- FOR SELECT
- TO authenticated
- USING (user_id IN (SELECT supabase_auth_id FROM public.profile_picture_teammate_ids()));
- END IF;
-END
-$$;
diff --git a/frontend/editor/src/core/tests/stubbed/avatar-fixtures.ts b/frontend/editor/src/core/tests/stubbed/avatar-fixtures.ts
deleted file mode 100644
index f1f5d75960..0000000000
--- a/frontend/editor/src/core/tests/stubbed/avatar-fixtures.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/** Synthetic avatars for the profile-picture walkthrough; base64 PNG, no external assets. */
-
-export const THUMBS: Record = {
- ana: "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAABLElEQVR4nO2Z0Q3CQAxDfVY3YAYmQ+oMjMAMWYZBGAGxAuIfJFQuvrZWeJ+ode0LbS9pO57ucIYwhzCHMIcwhzCHMIcwhzCHMIcwhzCHMIcwZxqkG+fD2y/z5THiQk27nf70/Yk2CVd2v/ywtQP8ZEuYgRKVDkOqDMxLdFuRZLB/jDJ5fnIVI12E8hXYHMIcwhzCHKJ4gDm3M5vTG7vyFUBiFSX7ak0FOqyougJKVH41JOxpqBJabkvbkbVBX2hce+L1IcwhzJkG6RrfA/G1yZLHaMIAy/vDPb4H4j8X6qP8XCisxyqhGK1FTsT+RcbuM4UT5khIVa1ASD9SICFYsgKhXv6MbMkKeAeIMf+fbvF6FdgbhDlEqQAx8g7uu0S73p5whjCHMIdbG8jyAo6jUqxC+H+BAAAAAElFTkSuQmCC",
- bruno:
- "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAABLElEQVR4nO2ZsRHCQBAD9RoXchkt0QYRZdAG3VAAiUN6ICGHGcb86W1rjg0ZW5b+sP13bvfDGc4Q5hDmEOYQ5hDmEOYQ5hDmEOYQ5hDmEOYQ5kyDdON6evtlPl5GXKhpt9Ofvj/RJuHK7pcftnaAn2wJM1Ci0mFIlYF5iW4rkgz2j1Emz0+uYqSLUL4Cm0OYQ5hDmEMUDzDndmZzemNXvgJIrKJkX62pQIcVVVdAicqvhoQ9DVVCy21pO7I26AuNa0+8PoQ5hDnTIF3jeyC+NlnyGE0YYHl/uMf3QPznQn2UnwuF9VglFKO1yInYv8jYfaZwwhwJqaoVCOlHCiQES1Yg1MufkS1ZAe8AMeb/0y1erwJ7gzCHKBUgRt7BfZdoz9sDzhDmEOZwawNZXg38VHoXMWs8AAAAAElFTkSuQmCC",
- chen: "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAABKElEQVR4nO2ZQQ6CUBBD+xs8pmyNFzJxOy68jnfxAu41MfinH2jGtzRQ2j8Cf4Z2uJzhDGEOYQ5hDmEOYQ5hDmEOYQ5hDmEOYQ5hDmHONEg3jqe3X+bbdcSFmnY7/en7E20Srux++WFrB/jJljADJSodhlQZmJfotiLJYP8YZfL85CpGugjlK7A5hDmEOYQ5RPEAc25nNqc3duUrgMQqSvbVmgp0WFF1BZSo/GpI2NNQJbTclrYja4O+0Lj2xOtDmEOYMw3SNb4H4muTJY/RhAGW94d7fA/Efy7UR/m5UFiPVUIxWouciP2LjN1nCifMkZCqWoGQfqRAQrBkBUK9/BnZkhXwDhBj/j/d4vUqsDcIc4hSAWLkHdx3iXZ/PuAMYQ5hDrc2kOUFUX1U+A3vluUAAAAASUVORK5CYII=",
- dara: "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAABKklEQVR4nO2ZsQ7CQAxDfVbFl+T/GFkYmdj4PKTO/AUSO0ioXHxtrfBG1Lr2hbaXtN1PBzhDmEOYQ5hDmEOYQ5hDmEOYQ5hDmEOYQ5hDmDMN0o3j5e2X+XYecaGm3U5/+v5Em4Qru19+2NoBfrIlzECJSochVQbmJbqtSDLYP0aZPD+5ipEuQvkKbA5hDmEOYQ5RPMCc25nN6Y1d+QogsYqSfbWmAh1WVF0BJSq/GhL2NFQJLbel7cjaoC80rj3x+hDmEOZMg3SN74H42mTJYzRhgOX94R7fA/GfC/VRfi4U1mOVUIzWIidi/yJj95nCCXMkpKpWIKQfKZAQLFmBUC9/RrZkBbwDxJj/T7d4vQrsDcIcolSAGHkH912iPR9XOEOYQ5jDrQ1keQG4olC7Jj75+wAAAABJRU5ErkJggg==",
- eli: "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAABKklEQVR4nO2Z0QnCQBBE5wZbEazHPoQtJZVYjiBiK+K/AYm3c0mG9X1KMpm5Ncntpl1OdzhDmEOYQ5hDmEOYQ5hDmEOYQ5hDmEOYQ5hDmHMYpBvX48cv0/kx4kJNu52e+56jTcKV3S8/bO0AP9kSZqBEpcOQKgPzEt1WJBnsH6NMnp9cxUgXoXwFNocwhzCHMIcoHmDK7cym9MaufAWQWEXJvlpTgQ4rqq6AEpVfDQl7GqqEltvSdmRt0Bca1554fQhzCHMOg3SN74H42mTJYzRhgOX94R7fA/GfC/VRfi4U1mOVUIzWIidi/yJj95nCCXMkpKpWIKQfKZAQLFmBUC9/RrZkBbwDxJj/T7d4vQrsDcIcolSAGHkH912iPW8vOEOYQ5jDrQ1keQMS9VT/W8XeQQAAAABJRU5ErkJggg==",
-};
-
-export const FULLS: Record = {
- ana: "iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAIAAADTED8xAAAFRElEQVR4nO3d220cRxBAUdFQBo6BkRFwDA7BMUwyDsQhEE5BMPxjEDBB7Wt2puue8y0I3b11e5YiuXp5fXv/BlW/HL0AOJIASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AZD2/egFtGy///rTP/PbH38/ZS386+X17d1JHDvxX9PDrgRw0rn/PyXsQQALjP5HMngsASwz+h/J4FEEsNjofySD+wlgydH/SAb38H2Ataf/PMtYlCfAnJnzKLiBJ8CQ6T/zws5MAKOG7OTLOyEBTBuvJRZ5HgIYOFgLLfVwApg5Usst+CgCGDtMiy77yQQweYyWXvxzCGD4AA3Ywq4EQJoA5t+dYzayBwEkhmbYdh5IAKQJoHJfjtzU/QRAmgBCN+Xgrd1MAKQJoHVHjt/gtQRAmgByt2NkmxcSAGkCIE0AxTcGqc1+TQCkCYA0AZAmANIEEP2iMLjlTwmANAGQJgDSBECaAEgTAGkCIE0ApAmANAGQJgDSBECaAKL/w25wy58SAGkCIE0ApAmANAEUvyhMbfZrAiBNAKQJIPfGILLNCwmANAG0bsfxG7yWAEgTQOiOHLy1mwmANAFUbsqRm7qfAEgTQOK+HLadBxLA/KEZs5E9CIA0AQy/OwdsYVcCmDxASy/+OQQwdowWXfaTCWDmMC234KMIYOBILbTUwwlg2mAtscjzEMCo8Tr58k5IAHOG7LQLO7OX17f3o9ewqvP8V7tG/2aeAMuP3UmWsShPgIUfBUb/fgJYMgOj/ygCWCwDo/9YAlgmA6O/BwGcvQRzvysBnK4HE/9MAiDN9wFIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgLTvRy+gxY9Dn40fh96XX4g5OQHswq9ErkIA630whF8ZeyABrPrRQDJ4CAGs/QGJMriTAG7ns0EH8H2A5af/bItZiyfAqGnzjuhangBzpv/8yzshT4CZs+VRcCFPgIHTv+KCjyKAscO06LKfTACTx2jpxT+HAIYP0IAt7EoA80dnzEb2IIDE0AzbzgMJoDIuIzd1PwGEBmXw1m4mgNaIjN/gtQSQG47INi8kgOJYpDb7NQFEByK45U8JoDsKW3XjHwmAtHoA8Vtwa2+/HoCX/1v+ELoBmP7/lI+iGwB0AyjfeZ/aqgdSDCD7Yn9tSx5LMQDoBtC85y609Q4nFwB0AwjecNfaYkcUCqD20t5sKx1UKADoBpC61e63ZY6rEgB0A+jcZw+0NQ4tEQB0A4jcZHvYAkc3PwDoBlC4w3a1TT/A4QFAN4Dxt9dzbKOPcXIA8FMCIG1sALMf3E+2zT3MsQFAN4DBN9ZRtqFHOjMAuJAASBsYwNSH9eG2iQc7MADoBjDyljqPbdzxTgsAriIA0kYFMO8BfULbrEMeFQBcSwCkCYC0OQEMe296Ztugo54TANxAAKQNCWDSQ3kJ25QDHxIA3EYApAmAtAkBjHk/upZtxLFPCABuJgDSBECaAEhbPoAZX4otalv/8JcPAO4hANIEQJoASFs7gAFfhK1uW/wlWDsAuJMASBMAaQIgTQCkLRzA6v/+MMa28guxcABwv5c///rxgL8G1uQJQJoASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AfCt7B9ECS6hVYEPQwAAAABJRU5ErkJggg==",
- bruno:
- "iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAIAAADTED8xAAAFRUlEQVR4nO3dy20cRxRAUdFQIL1jSk7DK4bhNJyNA+DGS+egjeGNQcAENb+e7nr3nLUgVNW8Wz0UydHL++vbN6j65egFwJEEQJoASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYC070cvoGX747ef/pm/fv39KWvhXy/vr29O4tiJ/5oediWAk879/ylhDwJYYPQ/ksFjCWCZ0f9IBo8igMVG/yMZ3E8AS47+RzK4h+8DrD3951nGojwB5sycR8ENPAGGTP+ZF3ZmAhg1ZCdf3gkJYNp4LbHI8xDAwMFaaKmHE8DMkVpuwUcRwNhhWnTZTyaAyWO09OKfQwDDB2jAFnYlANIEMP/uHLORPQggMTTDtvNAAiBNAJX7cuSm7icA0gQQuikHb+1mAiBNAK07cvwGryUA0gSQux0j27yQAEgTAGkCKL4xSG32awIgTQCkCYA0AZAmgOgXhcEtf0oApAmANAGQJgDSBECaAEgTAGkCIE0ApAmANAGQJgDSBBD9H3aDW/6UAEgTAGkCIE0ApAmg+EVharNfEwBpAiBNALk3BpFtXkgApAmgdTuO3+C1BECaAEJ35OCt3UwApAmgclOO3NT9BECaABL35bDtPJAA5g/NmI3sQQCkCWD43TlgC7sSwOQBWnrxzyGAsWO06LKfTAAzh2m5BR9FAANHaqGlHk4A0wZriUWehwBGjdfJl3dCApgzZKdd2Jm9vL++Hb2GVZ3nv9o1+jfzBFh+7E6yjEV5Aiz8KDD69xPAkhkY/UcRwGIZGP3HEsAyGRj9PQjg7CWY+10J4HQ9mPhnEgBpvg9AmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKR9P3oBLX4c+mz8OPS+/ELMyQlgF34lchUCWO+DIfzK2AMJYNWPBpLBQwhg7Q9IlMGdBHA7nw06gO8DLD/9Z1vMWjwBRk2bd0TX8gSYM/3nX94JeQLMnC2Pggt5Agyc/hUXfBQBjB2mRZf9ZAKYPEZLL/45BDB8gAZsYVcCmD86YzayBwEkhmbYdh5IAJVxGbmp+wkgNCiDt3YzAbRGZPwGryWA3HBEtnkhARTHIrXZrwkgOhDBLX9KAN1R2Kob/0gApNUDiN+CW3v79QC8/N/yh9ANwPT/p3wU3QCgG0D5zvvUVj2QYgDZF/trW/JYigFAN4DmPXehrXc4uQCgG0DwhrvWFjuiUAC1l/ZmW+mgQgFAN4DUrXa/LXNclQCgG0DnPnugrXFoiQCgG0DkJtvDFji6+QFAN4DCHbarbfoBDg8AugGMv72eYxt9jJMDgJ8SAGljA5j94H6ybe5hjg0AugEMvrGOsg090pkBwIUEQNrAAKY+rA+3TTzYgQFAN4CRt9R5bOOOd1oAcBUBkDYqgHkP6BPaZh3yqADgWgIgTQCkzQlg2HvTM9sGHfWcAOAGAiBtSACTHspL2KYc+JAA4DYCIE0ApE0IYMz70bVsI459QgBwMwGQJgDSBEDa8gHM+FJsUdv6h798AHAPAZAmANIEQNraAQz4Imx12+IvwdoBwJ0EQJoASBMAaQIgbeEAVv/3hzG2lV+IhQOA+738+PPvB/w1sCZPANIEQJoASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYBvZf8AXQg1Y7gVrqcAAAAASUVORK5CYII=",
- chen: "iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAIAAADTED8xAAAFQElEQVR4nO3dO24cRxRAUdEQl2mnBjdEwGk78Ha8F23AcGIQMEHNr6e73j0nFoSqmnerhyI5enl9f/sGVb8cvQA4kgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkPb96AW0bL/+/tM/89uffzxlLfzr5fX9zUkcO/Ff08OuBHDSuf8/JexBAAuM/kcyeCwBLDP6H8ngUQSw2Oh/JIP7CWDJ0f9IBvfwfYC1p/88y1iUJ8CcmfMouIEnwJDpP/PCzkwAo4bs5Ms7IQFMG68lFnkeAhg4WAst9XACmDlSyy34KAIYO0yLLvvJBDB5jJZe/HMIYPgADdjCrgRAmgDm351jNrIHASSGZth2HkgApAmgcl+O3NT9BECaAEI35eCt3UwApAmgdUeO3+C1BECaAHK3Y2SbFxIAaQIgTQDFNwapzX5NAKQJgDQBkCYA0gQQ/aIwuOVPCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AZAmANIEQJoAov/DbnDLnxIAaQIgTQCkCYA0ARS/KExt9msCIE0ApAkg98Ygss0LCYA0AbRux/EbvJYASBNA6I4cvLWbCYA0AVRuypGbup8ASBNA4r4ctp0HEsD8oRmzkT0IgDQBDL87B2xhVwKYPEBLL/45BDB2jBZd9pMJYOYwLbfgowhg4EgttNTDCWDaYC2xyPMQwKjxOvnyTkgAc4bstAs7s5fX97ej17Cq8/xXu0b/Zp4Ay4/dSZaxKE+AhR8FRv9+AlgyA6P/KAJYLAOj/1gCWCYDo78HAZy9BHO/KwGcrgcT/0wCIM33AUgTAGkCIE0ApAmANAGQJgDSBECaAEgTAGkCIE0ApAmAtO9HL6DFj0OfjR+H3pdfiDk5AezCr0SuQgDrfTCEXxl7IAGs+tFAMngIAaz9AYkyuJMAbuezQQfwfYDlp/9si1mLJ8CoafOO6FqeAHOm//zLOyFPgJmz5VFwIU+AgdO/4oKPIoCxw7Tosp9MAJPHaOnFP4cAhg/QgC3sSgDzR2fMRvYggMTQDNvOAwmgMi4jN3U/AYQGZfDWbiaA1oiM3+C1BJAbjsg2LySA4likNvs1AUQHIrjlTwmgOwpbdeMfCYC0egDxW3Brb78egJf/W/4QugGY/v+Uj6IbAHQDKN95n9qqB1IMIPtif21LHksxAOgG0LznLrT1DicXAHQDCN5w19piRxQKoPbS3mwrHVQoAOgGkLrV7rdljqsSAHQD6NxnD7Q1Di0RAHQDiNxke9gCRzc/AOgGULjDdrVNP8DhAUA3gPG313Nso49xcgDwUwIgbWwAsx/cT7bNPcyxAUA3gME31lG2oUc6MwC4kABIGxjA1If14baJBzswAOgGMPKWOo9t3PFOCwCuIgDSRgUw7wF9QtusQx4VAFxLAKQJgLQ5AQx7b3pm26CjnhMA3EAApA0JYNJDeQnblAMfEgDcRgCkCYC0CQGMeT+6lm3EsU8IAG4mANIEQJoASFs+gBlfii1qW//wlw8A7iEA0gRAmgBIWzuAAV+ErW5b/CVYOwC4kwBIEwBpAiBNAKQtHMDq//4wxrbyC7FwAHC/l79+/P2AvwbW5AlAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQB8K3sH4FUN9lV36c2AAAAAElFTkSuQmCC",
- dara: "iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAIAAADTED8xAAAFQklEQVR4nO3dO24cRxRAUdEQuJLen0MmDh058/IIOPYuBBhODAImqPn1dNe758SCUFXzbvVQJEcv72+v36Dql6MXAEcSAGkCIE0ApAmANAGQJgDSBECaAEgTAGkCIE0ApAmANAGQJgDSBECaAEgTAGkCIE0ApAmANAGQJgDSBECaAEgTAGkCIE0ApAmANAGQJgDSBECaAEgTAGkCIE0ApAmANAGQJgDSBECaAEgTAGkCIE0ApAmANAGQJgDSBECaAEgTAGkCIE0ApAmANAGQJgDSvh+9gJbt199/+mf++vO3p6yFf728v706iWMn/mt62JUATjr3/6eEPQhggdH/SAaPJYBlRv8jGTyKABYb/Y9kcD8BLDn6H8ngHr4PsPb0n2cZi/IEmDNzHgU38AQYMv1nXtiZCWDUkJ18eSckgGnjtcQiz0MAAwdroaUeTgAzR2q5BR9FAGOHadFlP5kAJo/R0ot/DgEMH6ABW9iVAEgTwPy7c8xG9iCAxNAM284DCYA0AVTuy5Gbup8ASBNA6KYcvLWbCYA0AbTuyPEbvJYASBNA7naMbPNCAiBNAKQJoPjGILXZrwmANAGQJgDSBECaAKJfFAa3/CkBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIE0D0f9gNbvlTAiBNAKQJgDQBkCaA4heFqc1+TQCkCYA0AeTeGES2eSEBkCaA1u04foPXEgBpAgjdkYO3djMBkCaAyk05clP3EwBpAkjcl8O280ACmD80YzayBwGQJoDhd+eALexKAJMHaOnFP4cAxo7Rost+MgHMHKblFnwUAQwcqYWWejgBTBusJRZ5HgIYNV4nX94JCWDOkJ12YWf28v72evQaVnWe/2rX6N/ME2D5sTvJMhblCbDwo8Do308AS2Zg9B9FAItlYPQfSwDLZGD09yCAs5dg7nclgNP1YOKfSQCk+T4AaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCYA0AZD2/egFtPhx6LPx49D78gsxJyeAXfiVyFUIYL0PhvArYw8kgFU/GkgGDyGAtT8gUQZ3EsDtfDboAL4PsPz0n20xa/EEGDVt3hFdyxNgzvSff3kn5Akwc7Y8Ci7kCTBw+ldc8FEEMHaYFl32kwlg8hgtvfjnEMDwARqwhV0JYP7ojNnIHgSQGJph23kgAVTGZeSm7ieA0KAM3trNBNAakfEbvJYAcsMR2eaFBFAci9RmvyaA6EAEt/wpAXRHYatu/CMBkFYPIH4Lbu3t1wPw8n/LH0I3ANP/n/JRdAOAbgDlO+9TW/VAigFkX+yvbcljKQYA3QCa99yFtt7h5AKAbgDBG+5aW+yIQgHUXtqbbaWDCgUA3QBSt9r9tsxxVQKAbgCd++yBtsahJQKAbgCRm2wPW+Do5gcA3QAKd9iutukHODwA6AYw/vZ6jm30MU4OAH5KAKSNDWD2g/vJtrmHOTYA6AYw+MY6yjb0SGcGABcSAGkDA5j6sD7cNvFgBwYA3QBG3lLnsY073mkBwFUEQNqoAOY9oE9om3XIowKAawmANAGQNieAYe9Nz2wbdNRzAoAbCIC0IQFMeigvYZty4EMCgNsIgDQBkDYhgDHvR9eyjTj2CQHAzQRAmgBIEwBpywcw40uxRW3rH/7yAcA9BECaAEgTAGlrBzDgi7DVbYu/BGsHAHcSAGkCIE0ApAmAtIUDWP3fH8bYVn4hFg4A7vfy4+8/HvDXwJo8AUgTAGkCIE0ApAmANAGQJgDSBECaAEgTAGkCIE0ApAmANAGQJgC+lf0DFy4neu4J5X4AAAAASUVORK5CYII=",
- eli: "iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAIAAADTED8xAAAFQklEQVR4nO3dXWocRxSAUSt4KwGtJ/sQ9FKykiwnIEK2YkJegiBCnr+e7rrfOc/GVNXcr3pkSeOXt9f3b1D1y9ELgCMJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwBp349eQMv2x68//TO///bXU9bCv17eXt+dxLET/zU97EoAJ537/1PCHgSwwOh/JIPHEsAyo/+RDB5FAIuN/kcyuJ8Alhz9j2RwD98HWHv6z7OMRXkCzJk5j4IbeAIMmf4zL+zMBDBqyE6+vBMSwLTxWmKR5yGAgYO10FIPJ4CZI7Xcgo8igLHDtOiyn0wAk8do6cU/hwCGD9CALexKAKQJYP7dOWYjexBAYmiGbeeBBECaACr35chN3U8ApAkgdFMO3trNBECaAFp35PgNXksApAkgdztGtnkhAZAmANIEUHxjkNrs1wRAmgBIEwBpAiBNANEvCoNb/pQASBMAaQIgTQCkCYA0AZAmANIEQJoASBMAaQIgTQCkCSD6P+wGt/wpAZAmANIEQJoASBNA8YvC1Ga/JgDSBECaAHJvDCLbvJAASBNA63Ycv8FrCYA0AYTuyMFbu5kASBNA5aYcuan7CYA0ASTuy2HbeSABzB+aMRvZgwBIE8Dwu3PAFnYlgMkDtPTin0MAY8do0WU/mQBmDtNyCz6KAAaO1EJLPZwApg3WEos8DwGMGq+TL++EBDBnyE67sDN7eXt9P3oNqzrPf7Vr9G/mCbD82J1kGYvyBFj4UWD07yeAJTMw+o8igMUyMPqPJYBlMjD6exDA2Usw97sSwOl6MPHPJADSfB+ANAGQJgDSBECaAEgTAGkCIE0ApAmANAGQJgDSBECaAEj7fvQCWvw49Nn4ceh9+YWYkxPALvxK5CoEsN4HQ/iVsQcSwKofDSSDhxDA2h+QKIM7CeB2Pht0AN8HWH76z7aYtXgCjJo274iu5QkwZ/rPv7wT8gSYOVseBRfyBBg4/Ssu+CgCGDtMiy77yQQweYyWXvxzCGD4AA3Ywq4EMH90xmxkDwJIDM2w7TyQACrjMnJT9xNAaFAGb+1mAmiNyPgNXksAueGIbPNCAiiORWqzXxNAdCCCW/6UALqjsFU3/pEASKsHEL8Ft/b26wF4+b/lD6EbgOn/T/kougFAN4DynfeprXogxQCyL/bXtuSxFAOAbgDNe+5CW+9wcgFAN4DgDXetLXZEoQBqL+3NttJBhQKAbgCpW+1+W+a4KgFAN4DOffZAW+PQEgFAN4DITbaHLXB08wOAbgCFO2xX2/QDHB4AdAMYf3s9xzb6GCcHAD8lANLGBjD7wf1k29zDHBsAdAMYfGMdZRt6pDMDgAsJgLSBAUx9WB9um3iwAwOAbgAjb6nz2MYd77QA4CoCIG1UAPMe0Ce0zTrkUQHAtQRAmgBImxPAsPemZ7YNOuo5AcANBEDakAAmPZSXsE058CEBwG0EQJoASJsQwJj3o2vZRhz7hADgZgIgTQCkCYC05QOY8aXYorb1D3/5AOAeAiBNAKQJgLS1AxjwRdjqtsVfgrUDgDsJgDQBkCYA0gRA2sIBrP7vD2NsK78QCwcA93v5+88fD/hrYE2eAKQJgDQBkCYA0gRAmgBIEwBpAiBNAKQJgDQBkCYA0gRAmgBIEwDfyv4BlqY3Zt5uyXwAAAAASUVORK5CYII=",
-};
diff --git a/frontend/editor/src/core/tests/stubbed/profile-pictures-walkthrough.spec.ts b/frontend/editor/src/core/tests/stubbed/profile-pictures-walkthrough.spec.ts
deleted file mode 100644
index e6e612b162..0000000000
--- a/frontend/editor/src/core/tests/stubbed/profile-pictures-walkthrough.spec.ts
+++ /dev/null
@@ -1,502 +0,0 @@
-import { test, expect } from "@app/tests/helpers/stub-test-base";
-import type { Page, Route } from "@playwright/test";
-import path from "node:path";
-import { openSettings } from "@app/tests/helpers/ui-helpers";
-import { THUMBS, FULLS } from "@app/tests/stubbed/avatar-fixtures";
-
-/**
- * Screenshot walkthrough of per-user profile pictures; dumps PNGs to
- * screenshots/profile-pictures. Throwaway harness for the UI report, not a assertion suite.
- */
-
-const SCREENSHOTS_DIR = path.resolve(
- process.cwd(),
- "screenshots",
- "profile-pictures",
-);
-
-function shotPath(name: string): string {
- return path.join(SCREENSHOTS_DIR, `${name}.png`);
-}
-
-async function settle(page: Page, ms = 450): Promise {
- await page.waitForTimeout(ms);
-}
-
-/** Ids 1-6 mirror a small org: three of six people have uploaded a picture. */
-const ROSTER = [
- { id: 1, username: "ana.oyelaran", team: "Design", avatar: "ana" },
- { id: 2, username: "bruno.katz", team: "Design", avatar: "bruno" },
- { id: 3, username: "chen.wei", team: "Design", avatar: null },
- { id: 4, username: "dara.singh", team: "Engineering", avatar: "dara" },
- { id: 5, username: "eli.novak", team: "Engineering", avatar: null },
- { id: 6, username: "fatima.rahman", team: "Engineering", avatar: null },
-];
-
-const TEAMS = [
- { id: 1, name: "Design" },
- { id: 2, name: "Engineering" },
-];
-
-function adminSettingsPayload() {
- return {
- users: ROSTER.map((u) => ({
- id: u.id,
- username: u.username,
- email: `${u.username}@example.com`,
- roleName: "adminUserSettings.user",
- rolesAsString: u.id === 1 ? "ROLE_ADMIN" : "ROLE_USER",
- enabled: u.id !== 6,
- authenticationType: "web",
- team: { id: u.team === "Design" ? 1 : 2, name: u.team },
- hasProfilePicture: u.avatar !== null,
- portalAccess: u.id === 1,
- teamLead: u.id === 4,
- })),
- userSessions: { "ana.oyelaran": true, "dara.singh": true },
- userLastRequest: {
- "ana.oyelaran": Date.now() - 60_000,
- "bruno.katz": Date.now() - 3_600_000,
- "chen.wei": Date.now() - 86_400_000,
- "dara.singh": Date.now() - 120_000,
- },
- userSettings: {},
- lockedUsers: [],
- totalUsers: ROSTER.length,
- activeUsers: 2,
- disabledUsers: 1,
- currentUsername: "ana.oyelaran",
- maxAllowedUsers: 100,
- availableSlots: 94,
- grandfatheredUserCount: 0,
- licenseMaxUsers: 100,
- premiumEnabled: true,
- mailEnabled: true,
- emailInvitesEnabled: true,
- teams: TEAMS,
- roleDetails: { ROLE_ADMIN: "Admin", ROLE_USER: "User" },
- };
-}
-
-/** Everything the avatar surfaces need. `ownAvatar` drives the signed-in user's own picture. */
-async function stubAvatarApis(
- page: Page,
- opts: { ownAvatar?: string | null } = {},
-): Promise {
- const { ownAvatar = "ana" } = opts;
-
- await page.route("**/api/v1/user/profile-picture", async (route: Route) => {
- if (route.request().method() !== "GET") {
- return route.fulfill({ json: { hasProfilePicture: true } });
- }
- if (!ownAvatar) return route.fulfill({ status: 404, body: "" });
- return route.fulfill({
- status: 200,
- contentType: "image/png",
- body: Buffer.from(FULLS[ownAvatar], "base64"),
- });
- });
-
- await page.route("**/api/v1/user/profile-pictures*", (route: Route) => {
- const requested =
- new URL(route.request().url()).searchParams
- .get("userIds")
- ?.split(",")
- .filter(Boolean) ?? [];
- const body: Record = {};
- for (const id of requested) {
- const person = ROSTER.find((u) => String(u.id) === id);
- if (person?.avatar) {
- body[id] = `data:image/png;base64,${THUMBS[person.avatar]}`;
- }
- }
- return route.fulfill({ json: body });
- });
-
- await page.route("**/ui-data/admin-settings", (route: Route) =>
- route.fulfill({ json: adminSettingsPayload() }),
- );
- await page.route("**/ui-data/account", (route: Route) =>
- route.fulfill({
- json: {
- username: "ana.oyelaran",
- role: "ROLE_ADMIN",
- settings: "{}",
- changeCredsFlag: false,
- oAuth2Login: false,
- saml2Login: false,
- mfaEnabled: false,
- mfaRequired: false,
- },
- }),
- );
- await page.route("**/ui-data/teams", (route: Route) =>
- route.fulfill({
- json: {
- teamsWithCounts: TEAMS.map((t) => ({
- id: t.id,
- name: t.name,
- userCount: ROSTER.filter((u) => u.team === t.name).length,
- })),
- teamLastRequest: {},
- teamOwners: { 2: ["dara.singh"] },
- },
- }),
- );
- await page.route("**/ui-data/teams/*", (route: Route) => {
- const teamId = Number(route.request().url().split("/").pop());
- const team = TEAMS.find((t) => t.id === teamId) ?? TEAMS[0];
- const members = ROSTER.filter(
- (u) => (u.team === "Design" ? 1 : 2) === teamId,
- );
- return route.fulfill({
- json: {
- team: { id: team.id, name: team.name },
- teamUsers: members.map((u) => ({
- id: u.id,
- username: u.username,
- email: `${u.username}@example.com`,
- enabled: true,
- roleName: "adminUserSettings.user",
- rolesAsString: "ROLE_USER",
- team: { id: team.id, name: team.name },
- })),
- availableUsers: [],
- userLastRequest: {},
- ownerUserIds: [4],
- },
- });
- });
- await page.route("**/api/v1/team/**", (route: Route) =>
- route.fulfill({ json: [] }),
- );
-}
-
-/** The portal only mounts for a session carrying portalAccess (RequirePortalAccess). */
-async function stubPortalSession(page: Page): Promise {
- await page.route("**/api/v1/auth/me", (route: Route) =>
- route.fulfill({
- json: {
- user: {
- id: 1,
- username: "ana.oyelaran",
- email: "ana.oyelaran@example.com",
- roles: ["ROLE_ADMIN"],
- role: "ROLE_ADMIN",
- portalAccess: true,
- team: { id: 1, name: "Design" },
- enabled: true,
- },
- portalAccess: true,
- },
- }),
- );
- await page.route("**/api/v1/policies**", (route: Route) =>
- route.fulfill({ json: [] }),
- );
- await page.route("**/api/v1/access/**", (route: Route) =>
- route.fulfill({ json: [] }),
- );
-}
-
-async function openAccountSection(page: Page): Promise {
- await openSettings(page);
- await page.getByText("Account Settings", { exact: true }).first().click();
- await expect(page.getByText("Profile picture").first()).toBeVisible({
- timeout: 10_000,
- });
- await settle(page);
-}
-
-async function openWorkspaceSection(page: Page, label: string): Promise {
- await openSettings(page);
- await page.getByText(label, { exact: true }).first().click();
- await settle(page, 1200);
-}
-
-/** Hand the profile-picture FilePicker a synthetic file, bypassing the OS dialog. */
-async function pickAvatarFile(page: Page, sizeBytes: number): Promise {
- const input = page.locator('input[type="file"][accept*="webp"]').last();
- await input.setInputFiles({
- name: "avatar.png",
- mimeType: "image/png",
- buffer:
- sizeBytes <= 0
- ? Buffer.from(FULLS.bruno, "base64")
- : Buffer.alloc(sizeBytes, 1),
- });
-}
-
-async function enableDarkMode(page: Page): Promise {
- await page.addInitScript(() => {
- localStorage.setItem("mantine-color-scheme", "dark");
- localStorage.setItem("mantine-color-scheme-value", "dark");
- });
- await page.emulateMedia({ colorScheme: "dark" });
-}
-
-async function enableRtl(page: Page): Promise {
- await page.addInitScript(() => {
- localStorage.setItem("i18nextLng", "ar-AR");
- localStorage.setItem("stirling-language", "ar-AR");
- localStorage.setItem("stirling-language-source", "user");
- const applyDir = () => {
- document.documentElement.setAttribute("dir", "rtl");
- document.documentElement.setAttribute("lang", "ar-AR");
- };
- if (document.documentElement) applyDir();
- else document.addEventListener("DOMContentLoaded", applyDir);
- });
-}
-
-const LOGGED_IN = {
- autoGoto: false as const,
- viewport: { width: 1600, height: 1000 },
- seedJwt: true,
- stubOptions: {
- enableLogin: true,
- isAdmin: true,
- user: {
- id: 1,
- username: "ana.oyelaran",
- email: "ana.oyelaran@example.com",
- roles: ["ROLE_ADMIN"],
- },
- },
-};
-
-/* ── Light pass ─────────────────────────────────────────────────────────── */
-
-test.describe("Profile pictures walkthrough", () => {
- test.use(LOGGED_IN);
-
- test("01_account_card_empty", async ({ page }) => {
- await stubAvatarApis(page, { ownAvatar: null });
- await page.goto("/", { waitUntil: "domcontentloaded" });
- await openAccountSection(page);
- await page.screenshot({ path: shotPath("01_account_card_empty_light") });
- });
-
- test("02_account_card_with_picture", async ({ page }) => {
- await stubAvatarApis(page);
- await page.goto("/", { waitUntil: "domcontentloaded" });
- await openAccountSection(page);
- await page.screenshot({
- path: shotPath("02_account_card_with_picture_light"),
- });
- });
-
- test("03_cropper_modal", async ({ page }) => {
- await stubAvatarApis(page);
- await page.goto("/", { waitUntil: "domcontentloaded" });
- await openAccountSection(page);
- await pickAvatarFile(page, 0);
- await expect(page.getByText("Crop Profile Picture")).toBeVisible({
- timeout: 10_000,
- });
- await settle(page, 900);
- await page.screenshot({ path: shotPath("03_cropper_modal_light") });
- });
-
- test("04_size_error", async ({ page }) => {
- await stubAvatarApis(page);
- await page.goto("/", { waitUntil: "domcontentloaded" });
- await openAccountSection(page);
- await pickAvatarFile(page, 6 * 1024 * 1024);
- await expect(page.getByText(/smaller than 5MB/i)).toBeVisible({
- timeout: 10_000,
- });
- await settle(page);
- await page.screenshot({ path: shotPath("04_size_error_light") });
- });
-
- test("05_sidebar_avatar", async ({ page }) => {
- await stubAvatarApis(page);
- await page.goto("/", { waitUntil: "domcontentloaded" });
- await expect(
- page.locator(".file-sidebar-bottom-avatar--picture"),
- ).toBeVisible({ timeout: 10_000 });
- await settle(page);
- await page.screenshot({ path: shotPath("05_sidebar_avatar_light") });
- });
-
- test("06_sidebar_initials", async ({ page }) => {
- await stubAvatarApis(page, { ownAvatar: null });
- await page.goto("/", { waitUntil: "domcontentloaded" });
- await expect(page.locator(".file-sidebar-bottom-avatar")).toBeVisible({
- timeout: 10_000,
- });
- await settle(page);
- await page.screenshot({ path: shotPath("06_sidebar_initials_light") });
- });
-
- test("07_people_roster", async ({ page }) => {
- await stubAvatarApis(page);
- await page.goto("/", { waitUntil: "domcontentloaded" });
- await openWorkspaceSection(page, "People");
- await page.screenshot({ path: shotPath("07_people_roster_light") });
- });
-
- test("08_team_details", async ({ page }) => {
- await stubAvatarApis(page);
- await page.goto("/", { waitUntil: "domcontentloaded" });
- await openWorkspaceSection(page, "Teams");
- const row = page.getByText("Design", { exact: true }).first();
- if (await row.isVisible().catch(() => false)) {
- await row.click();
- await settle(page, 1200);
- }
- await page.screenshot({ path: shotPath("08_team_details_light") });
- });
-
- test("09_portal_users", async ({ page }) => {
- await stubAvatarApis(page);
- await stubPortalSession(page);
- await page.goto("/processor/users", { waitUntil: "domcontentloaded" });
- await settle(page, 2500);
- await page.screenshot({ path: shotPath("09_portal_users_light") });
- });
-
- /* ── Dark pass ────────────────────────────────────────────────────────── */
-
- test("01_account_card_empty_dark", async ({ page }) => {
- await enableDarkMode(page);
- await stubAvatarApis(page, { ownAvatar: null });
- await page.goto("/", { waitUntil: "domcontentloaded" });
- await openAccountSection(page);
- await page.screenshot({ path: shotPath("01_account_card_empty_dark") });
- });
-
- test("02_account_card_with_picture_dark", async ({ page }) => {
- await enableDarkMode(page);
- await stubAvatarApis(page);
- await page.goto("/", { waitUntil: "domcontentloaded" });
- await openAccountSection(page);
- await page.screenshot({
- path: shotPath("02_account_card_with_picture_dark"),
- });
- });
-
- test("03_cropper_modal_dark", async ({ page }) => {
- await enableDarkMode(page);
- await stubAvatarApis(page);
- await page.goto("/", { waitUntil: "domcontentloaded" });
- await openAccountSection(page);
- await pickAvatarFile(page, 0);
- await expect(page.getByText("Crop Profile Picture")).toBeVisible({
- timeout: 10_000,
- });
- await settle(page, 900);
- await page.screenshot({ path: shotPath("03_cropper_modal_dark") });
- });
-
- test("04_size_error_dark", async ({ page }) => {
- await enableDarkMode(page);
- await stubAvatarApis(page);
- await page.goto("/", { waitUntil: "domcontentloaded" });
- await openAccountSection(page);
- await pickAvatarFile(page, 6 * 1024 * 1024);
- await expect(page.getByText(/smaller than 5MB/i)).toBeVisible({
- timeout: 10_000,
- });
- await settle(page);
- await page.screenshot({ path: shotPath("04_size_error_dark") });
- });
-
- test("05_sidebar_avatar_dark", async ({ page }) => {
- await enableDarkMode(page);
- await stubAvatarApis(page);
- await page.goto("/", { waitUntil: "domcontentloaded" });
- await expect(
- page.locator(".file-sidebar-bottom-avatar--picture"),
- ).toBeVisible({ timeout: 10_000 });
- await settle(page);
- await page.screenshot({ path: shotPath("05_sidebar_avatar_dark") });
- });
-
- test("06_sidebar_initials_dark", async ({ page }) => {
- await enableDarkMode(page);
- await stubAvatarApis(page, { ownAvatar: null });
- await page.goto("/", { waitUntil: "domcontentloaded" });
- await expect(page.locator(".file-sidebar-bottom-avatar")).toBeVisible({
- timeout: 10_000,
- });
- await settle(page);
- await page.screenshot({ path: shotPath("06_sidebar_initials_dark") });
- });
-
- test("07_people_roster_dark", async ({ page }) => {
- await enableDarkMode(page);
- await stubAvatarApis(page);
- await page.goto("/", { waitUntil: "domcontentloaded" });
- await openWorkspaceSection(page, "People");
- await page.screenshot({ path: shotPath("07_people_roster_dark") });
- });
-
- test("08_team_details_dark", async ({ page }) => {
- await enableDarkMode(page);
- await stubAvatarApis(page);
- await page.goto("/", { waitUntil: "domcontentloaded" });
- await openWorkspaceSection(page, "Teams");
- const row = page.getByText("Design", { exact: true }).first();
- if (await row.isVisible().catch(() => false)) {
- await row.click();
- await settle(page, 1200);
- }
- await page.screenshot({ path: shotPath("08_team_details_dark") });
- });
-
- test("09_portal_users_dark", async ({ page }) => {
- await enableDarkMode(page);
- await stubAvatarApis(page);
- await stubPortalSession(page);
- await page.goto("/processor/users", { waitUntil: "domcontentloaded" });
- await settle(page, 2500);
- await page.screenshot({ path: shotPath("09_portal_users_dark") });
- });
-
- test("12_remove_confirm", async ({ page }) => {
- await stubAvatarApis(page);
- await page.goto("/", { waitUntil: "domcontentloaded" });
- await openAccountSection(page);
- await page.getByRole("button", { name: "Remove" }).first().click();
- await expect(page.getByText("Remove profile picture")).toBeVisible({
- timeout: 10_000,
- });
- await settle(page, 700);
- await page.screenshot({ path: shotPath("12_remove_confirm_light") });
- });
-
- test("12_remove_confirm_dark", async ({ page }) => {
- await enableDarkMode(page);
- await stubAvatarApis(page);
- await page.goto("/", { waitUntil: "domcontentloaded" });
- await openAccountSection(page);
- await page.getByRole("button", { name: "Remove" }).first().click();
- await expect(page.getByText("Remove profile picture")).toBeVisible({
- timeout: 10_000,
- });
- await settle(page, 700);
- await page.screenshot({ path: shotPath("12_remove_confirm_dark") });
- });
-
- /* ── RTL pass (layout-sensitive views only) ───────────────────────────── */
-
- test("10_account_card_rtl", async ({ page }) => {
- await enableRtl(page);
- await stubAvatarApis(page);
- await page.goto("/", { waitUntil: "domcontentloaded" });
- await openSettings(page);
- await settle(page, 1200);
- await page.screenshot({ path: shotPath("10_account_card_rtl_light") });
- });
-
- test("11_people_roster_rtl", async ({ page }) => {
- await enableRtl(page);
- await stubAvatarApis(page);
- await page.goto("/", { waitUntil: "domcontentloaded" });
- await openSettings(page);
- await settle(page, 1500);
- await page.screenshot({ path: shotPath("11_people_roster_rtl_light") });
- });
-});
diff --git a/frontend/editor/src/portal/api/memberAvatars.ts b/frontend/editor/src/portal/api/memberAvatars.ts
deleted file mode 100644
index 34631d6244..0000000000
--- a/frontend/editor/src/portal/api/memberAvatars.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-/**
- * Per-flavor source of roster avatar URLs, resolved via the `@app/*` alias like `usersBackend`.
- * Self-hosted keys by user id (data URLs); SaaS keys by Supabase auth uuid (signed storage URLs).
- */
-export interface MemberAvatarSource {
- /**
- * URLs for the given avatar keys, keyed the same way. Keys with no picture, or that the caller
- * may not see, are absent so the row falls back to initials. Never rejects: avatars decorate.
- */
- resolve(keys: string[]): Promise>;
-}
diff --git a/frontend/editor/src/portal/api/users.ts b/frontend/editor/src/portal/api/users.ts
index 747fe29311..329e32c133 100644
--- a/frontend/editor/src/portal/api/users.ts
+++ b/frontend/editor/src/portal/api/users.ts
@@ -37,7 +37,7 @@ export interface Member {
lastActive: string;
/**
* Optional avatar image; falls back to initials when absent. Self-hosted supplies a data URL (the
- * bearer-token transport rules out a plain image URL); SaaS supplies a signed storage URL.
+ * bearer-token transport rules out a plain image URL); SaaS has none yet.
*/
avatarUrl?: string;
/** Backend linkage for row actions (absent on pure fixtures). */
@@ -285,9 +285,10 @@ const AVATAR_BATCH_SIZE = 200;
/**
* Roster avatars as data URLs, keyed by user id. Data URLs because the portal authenticates with a
* bearer token, which an `
` request would not carry. Avatars are decoration, so a failure
- * degrades to initials rather than failing the roster.
+ * degrades to initials rather than failing the roster. Kept on the portal's own transport rather
+ * than reusing the editor's service, which would pull the editor apiClient into this bundle.
*/
-export async function fetchAvatarThumbnails(
+async function fetchAvatarThumbnails(
userIds: string[],
): Promise> {
const ids = Array.from(new Set(userIds)).filter(Boolean);
diff --git a/frontend/editor/src/portal/components/users/UsersDirectory.test.tsx b/frontend/editor/src/portal/components/users/UsersDirectory.test.tsx
index dd59dfe5da..c26fedcd5f 100644
--- a/frontend/editor/src/portal/components/users/UsersDirectory.test.tsx
+++ b/frontend/editor/src/portal/components/users/UsersDirectory.test.tsx
@@ -119,7 +119,7 @@ describe("UsersDirectory - member avatars", () => {
document.querySelector("img.sui-avatar__img");
it("shows the member's picture when the roster carried one", () => {
- // Self-hosted supplies a data URL and SaaS a signed URL; the row only cares that it has one.
+ // Self-hosted supplies a data URL; the row only cares that it has one.
const withPicture: Member = {
...MEMBER,
avatarUrl: "data:image/png;base64,AQID",
diff --git a/frontend/editor/src/proprietary/components/shared/config/ProfilePictureCard.test.tsx b/frontend/editor/src/proprietary/components/shared/config/ProfilePictureCard.test.tsx
new file mode 100644
index 0000000000..30076322dc
--- /dev/null
+++ b/frontend/editor/src/proprietary/components/shared/config/ProfilePictureCard.test.tsx
@@ -0,0 +1,179 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { act, fireEvent, render, screen, within } from "@testing-library/react";
+import { MantineProvider } from "@mantine/core";
+
+const h = vi.hoisted(() => ({
+ maxBytes: 5 * 1024 * 1024,
+ upload: vi.fn(),
+ remove: vi.fn(),
+ refresh: vi.fn(),
+ pictureUrl: null as string | null,
+ /** Set by the cropper stub so a test can drive the post-crop upload. */
+ cropComplete: null as ((blob: Blob) => void) | null,
+}));
+
+vi.mock("@app/services/profilePictureService", () => ({
+ MAX_PROFILE_PICTURE_BYTES: h.maxBytes,
+ PROFILE_PICTURE_ACCEPT: "image/png,image/jpeg,image/webp",
+ uploadProfilePicture: h.upload,
+ removeProfilePicture: h.remove,
+}));
+vi.mock("@app/hooks/useProfilePictureUrl", () => ({
+ useProfilePictureUrl: () => h.pictureUrl,
+ refreshOwnProfilePicture: h.refresh,
+}));
+// The real cropper pulls in react-easy-crop and a canvas; the card only cares that it opened.
+vi.mock("@app/components/shared/config/ProfilePictureCropper", () => ({
+ ProfilePictureCropper: ({
+ opened,
+ onCropComplete,
+ }: {
+ opened: boolean;
+ onCropComplete: (blob: Blob) => void;
+ }) => {
+ h.cropComplete = onCropComplete;
+ return opened ? : null;
+ },
+}));
+vi.mock("@app/components/shared/LocalIcon", () => ({
+ default: () => ,
+}));
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (_key: string, fallback?: string, vars?: Record) =>
+ (fallback ?? _key).replace(/\{\{(\w+)\}\}/g, (_match, name: string) =>
+ String(vars?.[name] ?? ""),
+ ),
+ i18n: { changeLanguage: vi.fn() },
+ }),
+}));
+
+import ProfilePictureCard from "@app/components/shared/config/ProfilePictureCard";
+
+function renderCard() {
+ return render(
+
+
+ ,
+ );
+}
+
+/** Hands the hidden file input a file of the given nominal size, bypassing the OS dialog. */
+function pickFile(sizeBytes: number) {
+ const input = document.querySelector(
+ 'input[type="file"]',
+ ) as HTMLInputElement;
+ const file = new File(["x"], "avatar.png", { type: "image/png" });
+ Object.defineProperty(file, "size", { value: sizeBytes });
+ Object.defineProperty(input, "files", { value: [file], configurable: true });
+ fireEvent.change(input);
+}
+
+/** Drives the cropper stub's callback, i.e. everything after the user confirms the crop. */
+async function finishCrop() {
+ await act(async () => {
+ await h.cropComplete?.(new Blob(["x"]));
+ });
+}
+
+beforeEach(() => {
+ h.upload.mockReset();
+ h.remove.mockReset();
+ h.refresh.mockReset().mockResolvedValue(undefined);
+ h.pictureUrl = null;
+ h.cropComplete = null;
+});
+
+describe("ProfilePictureCard - size gate", () => {
+ it("refuses a file over the limit and names the limit in the error", () => {
+ renderCard();
+ pickFile(h.maxBytes + 1);
+
+ expect(
+ screen.getByText("Please choose an image smaller than 5MB."),
+ ).toBeInTheDocument();
+ // The load-bearing assertion: an oversized file must not reach the cropper or the upload.
+ expect(screen.queryByTestId("cropper")).not.toBeInTheDocument();
+ expect(h.upload).not.toHaveBeenCalled();
+ });
+
+ it("lets a file at exactly the limit through to the cropper", () => {
+ renderCard();
+ pickFile(h.maxBytes);
+
+ expect(screen.getByTestId("cropper")).toBeInTheDocument();
+ expect(
+ screen.queryByText("Please choose an image smaller than 5MB."),
+ ).not.toBeInTheDocument();
+ });
+
+ it("clears a previous size error once an acceptable file is picked", () => {
+ renderCard();
+ pickFile(h.maxBytes + 1);
+ expect(
+ screen.getByText("Please choose an image smaller than 5MB."),
+ ).toBeInTheDocument();
+
+ pickFile(1024);
+
+ expect(
+ screen.queryByText("Please choose an image smaller than 5MB."),
+ ).not.toBeInTheDocument();
+ });
+});
+
+describe("ProfilePictureCard - error surface", () => {
+ it("shows the server's reason when the upload is rejected", async () => {
+ h.upload.mockRejectedValue({
+ response: { data: { message: "Unsupported image format" } },
+ });
+ renderCard();
+ pickFile(1024);
+ await finishCrop();
+
+ expect(screen.getByText("Unsupported image format")).toBeInTheDocument();
+ expect(h.refresh).not.toHaveBeenCalled();
+ });
+
+ it("falls back to a generic message when the upload failure carries none", async () => {
+ h.upload.mockRejectedValue(new Error("network"));
+ renderCard();
+ pickFile(1024);
+ await finishCrop();
+
+ expect(
+ screen.getByText(
+ "Could not upload your profile picture. Please try again.",
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it("surfaces a failed removal", async () => {
+ h.pictureUrl = "blob:avatar";
+ h.remove.mockRejectedValue(new Error("boom"));
+ renderCard();
+
+ fireEvent.click(screen.getByRole("button", { name: "Remove" }));
+ // Removal is confirmed in a modal, so the destructive click is the one inside it.
+ const dialog = await screen.findByRole("dialog");
+ await act(async () => {
+ fireEvent.click(within(dialog).getByRole("button", { name: "Remove" }));
+ });
+
+ expect(h.remove).toHaveBeenCalledTimes(1);
+ expect(
+ screen.getByText(
+ "Could not remove your profile picture. Please try again.",
+ ),
+ ).toBeInTheDocument();
+ });
+
+ it("refreshes every consumer after a successful upload", async () => {
+ h.upload.mockResolvedValue(undefined);
+ renderCard();
+ pickFile(1024);
+ await finishCrop();
+
+ expect(h.refresh).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/frontend/editor/src/proprietary/portal/memberAvatars.ts b/frontend/editor/src/proprietary/portal/memberAvatars.ts
deleted file mode 100644
index 41a5da8396..0000000000
--- a/frontend/editor/src/proprietary/portal/memberAvatars.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import type { MemberAvatarSource } from "@portal/api/memberAvatars";
-import { fetchAvatarThumbnails } from "@portal/api/users";
-
-/**
- * Self-hosted: avatar keys are backend user ids, resolved to data URLs by the batch endpoint (the
- * portal's bearer-token transport rules out a plain image URL). Also what the portal test project
- * resolves `@app/portal/memberAvatars` to, since it has no saas alias.
- */
-export const memberAvatars: MemberAvatarSource = {
- resolve: fetchAvatarThumbnails,
-};
diff --git a/frontend/editor/src/saas/portal/memberAvatars.ts b/frontend/editor/src/saas/portal/memberAvatars.ts
deleted file mode 100644
index f79bac9a60..0000000000
--- a/frontend/editor/src/saas/portal/memberAvatars.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import type { MemberAvatarSource } from "@portal/api/memberAvatars";
-import { supabase } from "@app/auth/supabase";
-
-/** Same bucket the editor's account page writes to. */
-const PROFILE_BUCKET = "profile-pictures";
-
-/** Signed-URL lifetime for roster avatars; the roster is refetched well inside this. */
-const AVATAR_URL_TTL_SECONDS = 60 * 60;
-
-/**
- * SaaS build: avatar keys are Supabase auth uuids and the picture lives at `/avatar`. Storage
- * RLS (migration V34) limits reads to teammates; paths outside the team error and are dropped.
- */
-export const memberAvatars: MemberAvatarSource = {
- async resolve(keys: string[]): Promise> {
- const ids = Array.from(new Set(keys)).filter(Boolean);
- if (ids.length === 0) return {};
- try {
- const { data, error } = await supabase.storage
- .from(PROFILE_BUCKET)
- .createSignedUrls(
- ids.map((id) => `${id}/avatar`),
- AVATAR_URL_TTL_SECONDS,
- );
- if (error || !data) return {};
- const byId: Record = {};
- for (const entry of data) {
- if (entry.error || !entry.signedUrl || !entry.path) continue;
- byId[entry.path.split("/")[0]] = entry.signedUrl;
- }
- return byId;
- } catch {
- return {};
- }
- },
-};
diff --git a/frontend/editor/src/saas/portal/usersBackend.ts b/frontend/editor/src/saas/portal/usersBackend.ts
index 41ebd8d03c..a4fbc03dfb 100644
--- a/frontend/editor/src/saas/portal/usersBackend.ts
+++ b/frontend/editor/src/saas/portal/usersBackend.ts
@@ -1,5 +1,4 @@
import type { UsersBackend } from "@portal/api/usersBackend";
-import { memberAvatars } from "@app/portal/memberAvatars";
import { apiClient } from "@portal/api/http";
import { tryGetPortalQueryClient } from "@portal/queryClient";
import { qk } from "@portal/queries/keys";
@@ -44,8 +43,6 @@ interface TeamMemberDTO {
/** "LEADER" | "MEMBER". */
role: string;
joinedAt?: string;
- /** Supabase auth uuid; the avatar lives at `/avatar` in the profile-pictures bucket. */
- supabaseAuthId?: string | null;
}
interface InvitationDTO {
@@ -170,17 +167,6 @@ export const usersBackend: UsersBackend = {
: [];
const members = (memberDtos ?? []).map((m) => toMember(m, team));
- const avatarUrls = await memberAvatars.resolve(
- (memberDtos ?? [])
- .map((m) => m.supabaseAuthId)
- .filter((id): id is string => Boolean(id)),
- );
- for (const [index, dto] of (memberDtos ?? []).entries()) {
- const avatarUrl = dto.supabaseAuthId
- ? avatarUrls[dto.supabaseAuthId]
- : undefined;
- if (avatarUrl) members[index].avatarUrl = avatarUrl;
- }
// Only genuinely-live invites: PENDING, and not past expiry. The backend
// returns every status and flips PENDING->EXPIRED on a daily sweep, so a
// past-expiry PENDING row can linger for up to a day - drop it here.