mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Drop the inert SaaS half of profile pictures and add real test coverage
This commit is contained in:
@@ -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 = '<span class="sev ' + sevClass(f.severity) + '">' + (f.severity || "note") + '</span>' +
|
||||
'<span>' + (f.fixed ? '<b style="color:var(--ok,#16a34a)">FIXED</b> ' : '') +
|
||||
'<b>' + (f.title || "") + '</b> — ' + (f.detail || "") +
|
||||
(f.fixed && f.fixNote ? ' <span class="fix"><b>Change:</b> ' + f.fixNote + '</span>'
|
||||
: f.fix ? ' <span class="fix"><b>Fix:</b> ' + f.fix + '</span>' : '') + '</span>';
|
||||
'<span><b>' + (f.title || "") + '</b> — ' + (f.detail || "") +
|
||||
(f.fix ? ' <span class="fix"><b>Fix:</b> ' + f.fix + '</span>' : '') + '</span>';
|
||||
notes.appendChild(li);
|
||||
});
|
||||
$("prev").disabled = state.i === 0;
|
||||
|
||||
+68
@@ -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<User> 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});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <uuid>/avatar} in profile-pictures. */
|
||||
private final String supabaseAuthId;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
-61
@@ -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 "<their auth uuid>/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
|
||||
$$;
|
||||
@@ -1,19 +0,0 @@
|
||||
/** Synthetic avatars for the profile-picture walkthrough; base64 PNG, no external assets. */
|
||||
|
||||
export const THUMBS: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
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=",
|
||||
};
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<string, string> = {};
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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") });
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, string>>;
|
||||
}
|
||||
@@ -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 `<img src>` 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<Record<string, string>> {
|
||||
const ids = Array.from(new Set(userIds)).filter(Boolean);
|
||||
|
||||
@@ -119,7 +119,7 @@ describe("UsersDirectory - member avatars", () => {
|
||||
document.querySelector<HTMLImageElement>("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",
|
||||
|
||||
@@ -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 ? <div data-testid="cropper" /> : null;
|
||||
},
|
||||
}));
|
||||
vi.mock("@app/components/shared/LocalIcon", () => ({
|
||||
default: () => <span />,
|
||||
}));
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (_key: string, fallback?: string, vars?: Record<string, unknown>) =>
|
||||
(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(
|
||||
<MantineProvider>
|
||||
<ProfilePictureCard displayName="Priya Raman" />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Hands the hidden file input a file of the given nominal size, bypassing the OS dialog. */
|
||||
function pickFile(sizeBytes: number) {
|
||||
const input = document.querySelector<HTMLInputElement>(
|
||||
'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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 `<uuid>/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<Record<string, string>> {
|
||||
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<string, string> = {};
|
||||
for (const entry of data) {
|
||||
if (entry.error || !entry.signedUrl || !entry.path) continue;
|
||||
byId[entry.path.split("/")[0]] = entry.signedUrl;
|
||||
}
|
||||
return byId;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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 `<uuid>/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.
|
||||
|
||||
Reference in New Issue
Block a user