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.