Compare commits
7 Commits
300b236d7d
...
929acf6964
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
929acf6964 | ||
|
|
362672cdbf | ||
|
|
1e3e420860 | ||
|
|
3a758393bf | ||
|
|
1a0be4130e | ||
|
|
98f8c0129a | ||
|
|
79e9cc5a2b |
@@ -51,12 +51,14 @@ public class PersonController {
|
||||
@RequestParam(required = false) String sort,
|
||||
@RequestParam(defaultValue = "0") @Min(0) int page,
|
||||
@RequestParam(defaultValue = "50") @Min(1) @Max(100) int size) {
|
||||
// Legacy top-N-by-document-count path (reader dashboard): preserved, now wrapped in the
|
||||
// paged contract so /api/persons always returns one shape.
|
||||
// Legacy top-N-by-document-count path (reader dashboard): preserved, wrapped in the
|
||||
// same envelope so /api/persons always returns one shape. It is explicitly NON-paged —
|
||||
// the top-N query returns the complete result, so PersonSearchResult.topN reports an
|
||||
// honest totalElements (= returned count) instead of pretending to be a page slice.
|
||||
if ("documentCount".equals(sort) && q == null) {
|
||||
int safeSize = Math.min(size, 50);
|
||||
List<PersonSummaryDTO> top = personService.findTopByDocumentCount(safeSize);
|
||||
return ResponseEntity.ok(PersonSearchResult.paged(top, 0, safeSize, top.size()));
|
||||
return ResponseEntity.ok(PersonSearchResult.topN(top));
|
||||
}
|
||||
|
||||
PersonFilter filter = PersonFilter.builder()
|
||||
|
||||
@@ -33,4 +33,18 @@ public record PersonSearchResult(
|
||||
int totalPages = pageSize == 0 ? 0 : (int) ((totalElements + pageSize - 1) / pageSize);
|
||||
return new PersonSearchResult(slice, totalElements, pageNumber, pageSize, totalPages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-paged factory for the legacy {@code sort=documentCount} top-N dashboard path.
|
||||
* That query returns the <em>complete</em> result in one shot — there is no further page
|
||||
* to fetch — so the envelope reports reality rather than pretending to be a slice of a
|
||||
* larger set: {@code totalElements} equals the number of rows actually returned,
|
||||
* {@code pageSize} equals that same count, and {@code totalPages} is 1 (or 0 when empty).
|
||||
* This avoids the earlier ambiguity where {@code totalElements} looked like a paged total.
|
||||
*/
|
||||
public static PersonSearchResult topN(List<PersonSummaryDTO> all) {
|
||||
int count = all.size();
|
||||
int totalPages = count == 0 ? 0 : 1;
|
||||
return new PersonSearchResult(all, count, 0, count, totalPages);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +175,36 @@ class PersonControllerTest {
|
||||
.andExpect(jsonPath("$.items[0].firstName").value("Käthe"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getPersons_topByDocumentCount_isNonPaged_totalElementsEqualsReturnedCount() throws Exception {
|
||||
// The top-N dashboard path is deliberately NON-paged: it returns the complete result
|
||||
// (no further page exists), so totalElements equals the number of rows returned and
|
||||
// totalPages is 1. Pinned so nobody "fixes" it into a misleading paged total.
|
||||
when(personService.findTopByDocumentCount(50))
|
||||
.thenReturn(List.of(mockPersonSummary("Käthe", "Raddatz"),
|
||||
mockPersonSummary("Hans", "Müller")));
|
||||
|
||||
mockMvc.perform(get("/api/persons").param("sort", "documentCount"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.items.length()").value(2))
|
||||
.andExpect(jsonPath("$.totalElements").value(2))
|
||||
.andExpect(jsonPath("$.pageNumber").value(0))
|
||||
.andExpect(jsonPath("$.pageSize").value(2))
|
||||
.andExpect(jsonPath("$.totalPages").value(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getPersons_topByDocumentCount_emptyResult_reportsZeroPages() throws Exception {
|
||||
when(personService.findTopByDocumentCount(50)).thenReturn(Collections.emptyList());
|
||||
|
||||
mockMvc.perform(get("/api/persons").param("sort", "documentCount"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.totalElements").value(0))
|
||||
.andExpect(jsonPath("$.totalPages").value(0));
|
||||
}
|
||||
|
||||
private PersonSummaryDTO mockPersonSummary(String firstName, String lastName) {
|
||||
return new PersonSummaryDTO() {
|
||||
public java.util.UUID getId() { return UUID.randomUUID(); }
|
||||
|
||||
@@ -648,6 +648,21 @@ class PersonRepositoryTest {
|
||||
assertThat(count).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void countByFilter_query_matchesSliceSize() {
|
||||
// The whole point of the shared FILTER_WHERE is that the slice and the count can never
|
||||
// drift. Pin the query (LIKE) path explicitly: countByFilter must equal the slice size
|
||||
// so a future edit to one query's LIKE clause is caught.
|
||||
seedDirectoryFixture();
|
||||
|
||||
List<PersonSummaryDTO> slice = personRepository.findByFilter(
|
||||
null, null, null, null, false, "Verlag", 50, 0);
|
||||
long count = personRepository.countByFilter(null, null, null, null, false, "Verlag");
|
||||
|
||||
assertThat(count).isEqualTo(slice.size());
|
||||
assertThat(count).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByFilter_projectsDocumentCount() {
|
||||
seedDirectoryFixture();
|
||||
|
||||
@@ -2,6 +2,9 @@ package org.raddatz.familienarchiv.person;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonType;
|
||||
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||
@@ -13,6 +16,11 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@@ -24,6 +32,9 @@ class PersonServiceIntegrationTest {
|
||||
@MockitoBean S3Client s3Client;
|
||||
@Autowired PersonService personService;
|
||||
@Autowired PersonRepository personRepository;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
|
||||
@PersistenceContext EntityManager entityManager;
|
||||
|
||||
@Test
|
||||
void findOrCreateByAlias_skipReturnsNull_noRecordCreated() {
|
||||
@@ -112,4 +123,48 @@ class PersonServiceIntegrationTest {
|
||||
|
||||
assertThat(personRepository.findById(target.getId())).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deletePerson_detachesSentAndReceivedReferences_beforeDelete_noOrphan() {
|
||||
// A person referenced as BOTH a document sender and a document receiver must delete
|
||||
// cleanly: deletePerson nulls the sender_id FK and removes the receiver join row first
|
||||
// (reassignSenderToNull → deleteReceiverReferences → deleteById), so no FK orphan and
|
||||
// the documents themselves survive.
|
||||
Person target = personRepository.save(Person.builder()
|
||||
.firstName("Weg").lastName("Person").provisional(true).build());
|
||||
Person bystander = personRepository.save(Person.builder()
|
||||
.firstName("Bleibt").lastName("Hier").build());
|
||||
|
||||
Document sent = documentRepository.save(Document.builder()
|
||||
.title("Sent letter").originalFilename("sent.pdf")
|
||||
.status(DocumentStatus.UPLOADED).sender(target).build());
|
||||
Document received = documentRepository.save(Document.builder()
|
||||
.title("Received letter").originalFilename("received.pdf")
|
||||
.status(DocumentStatus.UPLOADED).sender(bystander)
|
||||
.receivers(new java.util.HashSet<>(Set.of(target))).build());
|
||||
|
||||
// Persist the fixture and detach everything so the native @Modifying deletes operate on
|
||||
// the database directly without the persistence context holding stale references that
|
||||
// would re-flush a now-deleted person as a transient association.
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
personService.deletePerson(target.getId());
|
||||
|
||||
// Native @Modifying queries bypass the persistence context — clear it so the asserting
|
||||
// reads observe the post-delete database state, not stale managed entities.
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
assertThat(personRepository.findById(target.getId())).isEmpty();
|
||||
|
||||
Document reloadedSent = documentRepository.findById(sent.getId()).orElseThrow();
|
||||
assertThat(reloadedSent.getSender()).isNull();
|
||||
|
||||
Document reloadedReceived = documentRepository.findById(received.getId()).orElseThrow();
|
||||
assertThat(reloadedReceived.getReceivers())
|
||||
.noneMatch(p -> p.getId().equals(target.getId()));
|
||||
// The other person and the documents themselves survive the delete.
|
||||
assertThat(personRepository.findById(bystander.getId())).isPresent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,8 @@
|
||||
"persons_review_delete_confirm_text": "Diese Person wird endgültig gelöscht. Dokumentverweise bleiben erhalten, verlieren aber diese Person.",
|
||||
"persons_review_delete_confirm_button": "Person löschen",
|
||||
"persons_review_merge_label": "Mit welcher Person zusammenführen?",
|
||||
"persons_field_first_name": "Vorname",
|
||||
"persons_field_last_name": "Nachname",
|
||||
"persons_new_heading": "Neue Person",
|
||||
"persons_section_details": "Angaben zur Person",
|
||||
"person_edit_heading": "Person bearbeiten",
|
||||
|
||||
@@ -153,6 +153,8 @@
|
||||
"persons_review_delete_confirm_text": "This person will be permanently deleted. Document references are kept but lose this person.",
|
||||
"persons_review_delete_confirm_button": "Delete person",
|
||||
"persons_review_merge_label": "Merge into which person?",
|
||||
"persons_field_first_name": "First name",
|
||||
"persons_field_last_name": "Last name",
|
||||
"persons_new_heading": "New person",
|
||||
"persons_section_details": "Person details",
|
||||
"person_edit_heading": "Edit person",
|
||||
|
||||
@@ -153,6 +153,8 @@
|
||||
"persons_review_delete_confirm_text": "Esta persona se eliminará de forma permanente. Las referencias de documentos se conservan pero pierden a esta persona.",
|
||||
"persons_review_delete_confirm_button": "Eliminar persona",
|
||||
"persons_review_merge_label": "¿Fusionar con qué persona?",
|
||||
"persons_field_first_name": "Nombre",
|
||||
"persons_field_last_name": "Apellido",
|
||||
"persons_new_heading": "Nueva persona",
|
||||
"persons_section_details": "Datos de la persona",
|
||||
"person_edit_heading": "Editar persona",
|
||||
|
||||
@@ -8,27 +8,28 @@ type Person = components['schemas']['PersonSummaryDTO'];
|
||||
|
||||
let { person }: { person: Person } = $props();
|
||||
|
||||
// A card is "unconfirmed" when the importer could not identify the person: the explicit
|
||||
// provisional flag, the placeholder UNKNOWN type, or a literal "?" name from the spreadsheet.
|
||||
const isUnconfirmed = $derived(
|
||||
person.provisional === true ||
|
||||
person.personType === 'UNKNOWN' ||
|
||||
person.lastName === '?' ||
|
||||
(person.lastName ?? '').trim() === ''
|
||||
// "Unconfirmed" is exactly the `provisional` flag — the authoritative signal the importer
|
||||
// sets and the triage flow clears. The badge, the "Zu prüfen (N)" count and the
|
||||
// /persons/review list all key off this same flag, so badge ⇔ count ⇔ triage can never drift.
|
||||
const isUnconfirmed = $derived(person.provisional === true);
|
||||
|
||||
// An empty / "?" last name is a separate, purely defensive concern: it must not crash the
|
||||
// initials branch (reading lastName[0] on null throws) and must never render a "?" initial.
|
||||
// It implies the placeholder glyph but — on its own — no "unbestätigt" badge.
|
||||
const hasNoName = $derived(
|
||||
person.lastName == null || person.lastName.trim() === '' || person.lastName === '?'
|
||||
);
|
||||
|
||||
// A non-PERSON type (institution/group) gets a glyph; everything that is a real, confirmed
|
||||
// person gets initials. Unconfirmed persons never get a "?" initial — they get the neutral
|
||||
// placeholder glyph instead. Reading lastName[0] on a null/empty name would throw, so the
|
||||
// initials branch is gated on a non-empty name.
|
||||
// A non-PERSON type (institution/group) gets a typed glyph; a confirmed, named person gets
|
||||
// initials. Provisional entries and nameless entries fall back to the neutral placeholder glyph.
|
||||
const showGlyph = $derived(
|
||||
isUnconfirmed || (person.personType != null && person.personType !== 'PERSON')
|
||||
isUnconfirmed || hasNoName || (person.personType != null && person.personType !== 'PERSON')
|
||||
);
|
||||
|
||||
const initials = $derived.by(() => {
|
||||
const first = person.firstName?.[0] ?? '';
|
||||
const last = person.lastName?.[0] ?? '';
|
||||
return (first || last) + last;
|
||||
return first ? first + last : last;
|
||||
});
|
||||
|
||||
const documentCount = $derived(person.documentCount ?? 0);
|
||||
@@ -43,7 +44,7 @@ const documentCount = $derived(person.documentCount ?? 0);
|
||||
<div
|
||||
class={[
|
||||
'flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full font-serif text-base font-bold transition-colors',
|
||||
isUnconfirmed ? 'bg-muted text-ink-2' : 'bg-primary text-primary-fg'
|
||||
isUnconfirmed || hasNoName ? 'bg-muted text-ink-2' : 'bg-primary text-primary-fg'
|
||||
]}
|
||||
>
|
||||
{#if showGlyph}
|
||||
@@ -98,7 +99,6 @@ const documentCount = $derived(person.documentCount ?? 0);
|
||||
<!-- State conveyed by text + the muted placeholder shape, never colour alone (WCAG 1.4.1). -->
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full border border-line bg-muted px-2.5 py-0.5 font-sans text-xs font-semibold text-ink-2"
|
||||
aria-label={m.person_badge_unconfirmed()}
|
||||
>
|
||||
<svg
|
||||
class="h-3 w-3"
|
||||
|
||||
@@ -32,16 +32,18 @@ describe('PersonCard — confirmed person', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PersonCard — unconfirmed / malformed (regression: null-lastName crash)', () => {
|
||||
describe('PersonCard — unconfirmed badge keys off provisional only (badge ⇔ count ⇔ triage parity)', () => {
|
||||
it('renders without throwing when lastName is null', async () => {
|
||||
// Before the fix, `lastName[0]` threw at render for a null lastName.
|
||||
// Before the fix, `lastName[0]` threw at render for a null lastName. Empty-name
|
||||
// crash-safety is a SEPARATE concern from the badge: the placeholder glyph renders
|
||||
// regardless, but the "unbestätigt" badge only fires when provisional is true.
|
||||
const person = makePerson({
|
||||
lastName: null as unknown as string,
|
||||
displayName: '?',
|
||||
provisional: true
|
||||
});
|
||||
render(PersonCard, { props: { person } });
|
||||
// No throw + the placeholder avatar (an <img>) is present, never a "?" initial.
|
||||
// No throw + provisional → the badge is shown.
|
||||
await expect.element(page.getByText('unbestätigt')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -50,17 +52,36 @@ describe('PersonCard — unconfirmed / malformed (regression: null-lastName cras
|
||||
await expect.element(page.getByText('unbestätigt')).toBeVisible();
|
||||
});
|
||||
|
||||
it('shows a placeholder (no "?" initial) for a "?" name', async () => {
|
||||
it('does NOT show the badge for a "?" name when not provisional', async () => {
|
||||
// Empty/"?" name alone is no longer treated as unconfirmed — only `provisional` is.
|
||||
// This keeps the badge in lockstep with needsReviewCount and the /persons/review list.
|
||||
render(PersonCard, {
|
||||
props: { person: makePerson({ firstName: undefined, lastName: '?', displayName: '?' }) }
|
||||
props: {
|
||||
person: makePerson({ firstName: undefined, lastName: '?', displayName: '?' })
|
||||
}
|
||||
});
|
||||
await expect.element(page.getByText('unbestätigt')).toBeVisible();
|
||||
await expect.element(page.getByText('unbestätigt')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('treats an UNKNOWN type as unconfirmed', async () => {
|
||||
it('does NOT show the badge for an UNKNOWN type when not provisional', async () => {
|
||||
render(PersonCard, {
|
||||
props: { person: makePerson({ personType: 'UNKNOWN', displayName: 'Unklar' }) }
|
||||
});
|
||||
await expect.element(page.getByText('unbestätigt')).toBeVisible();
|
||||
await expect.element(page.getByText('unbestätigt')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the placeholder glyph (never a "?" initial) for an empty name even without provisional', async () => {
|
||||
// Crash-safety branch: a null/empty lastName must not throw and must not show "?".
|
||||
render(PersonCard, {
|
||||
props: {
|
||||
person: makePerson({
|
||||
firstName: undefined,
|
||||
lastName: null as unknown as string,
|
||||
displayName: 'Unbekannt'
|
||||
})
|
||||
}
|
||||
});
|
||||
await expect.element(page.getByText('Unbekannt')).toBeVisible();
|
||||
await expect.element(page.getByText('unbestätigt')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,11 +132,13 @@ const chipInactive = 'border-line bg-surface text-ink hover:bg-muted';
|
||||
|
||||
<!-- Show-all / Zu prüfen toggle: transcriber-only, reveals the import noise. -->
|
||||
{#if canWrite}
|
||||
<!-- No aria-label: the visible text IS the accessible name (WCAG 2.5.3 Label in Name).
|
||||
It flips between "Zu prüfen (N)" (off) and "Alle anzeigen" (on) and aria-checked
|
||||
carries the toggle state, so the announced name always matches what the user reads. -->
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={review}
|
||||
aria-label={m.persons_toggle_needs_review({ count: needsReviewCount })}
|
||||
class={[chipBase, 'sm:ml-auto', review ? chipActive : chipInactive]}
|
||||
onclick={() => setReview(!review)}
|
||||
>
|
||||
|
||||
@@ -99,7 +99,7 @@ const deleteBtn =
|
||||
<input type="hidden" name="id" value={person.id} />
|
||||
<input type="hidden" name="personType" value={person.personType ?? 'PERSON'} />
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-sans text-ink-2">{m.persons_filter_type_person()}</span>
|
||||
<span class="font-sans text-ink-2">{m.persons_field_first_name()}</span>
|
||||
<input
|
||||
name="firstName"
|
||||
bind:value={renameFirstName}
|
||||
@@ -107,7 +107,7 @@ const deleteBtn =
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-1 flex-col gap-1 text-sm">
|
||||
<span class="font-sans text-ink-2">{m.persons_section_details()}</span>
|
||||
<span class="font-sans text-ink-2">{m.persons_field_last_name()}</span>
|
||||
<input
|
||||
name="lastName"
|
||||
required
|
||||
|
||||
30
frontend/src/lib/shared/server/permissions.spec.ts
Normal file
30
frontend/src/lib/shared/server/permissions.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { hasWriteAll } from './permissions';
|
||||
|
||||
type Locals = { user?: { groups?: { permissions: string[] }[] } };
|
||||
|
||||
const localsWith = (permissions: string[][]): Locals => ({
|
||||
user: { groups: permissions.map((p) => ({ permissions: p })) }
|
||||
});
|
||||
|
||||
describe('hasWriteAll', () => {
|
||||
it('returns true when a group grants WRITE_ALL', () => {
|
||||
expect(hasWriteAll(localsWith([['READ_ALL', 'WRITE_ALL']]))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when WRITE_ALL is in any of several groups', () => {
|
||||
expect(hasWriteAll(localsWith([['READ_ALL'], ['WRITE_ALL']]))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when no group grants WRITE_ALL', () => {
|
||||
expect(hasWriteAll(localsWith([['READ_ALL'], ['ANNOTATE_ALL']]))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for an anonymous user (no locals.user)', () => {
|
||||
expect(hasWriteAll({})).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when the user has no groups', () => {
|
||||
expect(hasWriteAll({ user: {} })).toBe(false);
|
||||
});
|
||||
});
|
||||
14
frontend/src/lib/shared/server/permissions.ts
Normal file
14
frontend/src/lib/shared/server/permissions.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Server-side permission predicates derived from the authenticated user in `locals`.
|
||||
*
|
||||
* The user shape is intentionally narrowed to the only field these checks read
|
||||
* (`groups[].permissions`) so the helper works against `App.Locals` without importing it.
|
||||
*/
|
||||
type PermissionLocals = {
|
||||
user?: { groups?: { permissions: string[] }[] } | null;
|
||||
};
|
||||
|
||||
/** True when any of the user's groups grants WRITE_ALL. False for anonymous users. */
|
||||
export function hasWriteAll(locals: PermissionLocals): boolean {
|
||||
return locals.user?.groups?.some((group) => group.permissions.includes('WRITE_ALL')) ?? false;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
||||
import { parseBackendError, getErrorMessage } from '$lib/shared/errors';
|
||||
import { hasWriteAll } from '$lib/shared/server/permissions';
|
||||
|
||||
export async function load({
|
||||
params,
|
||||
@@ -15,11 +16,7 @@ export async function load({
|
||||
depends: (dep: string) => void;
|
||||
}) {
|
||||
depends('app:document');
|
||||
const canWrite =
|
||||
locals.user?.groups?.some((g: { permissions: string[] }) =>
|
||||
g.permissions.includes('WRITE_ALL')
|
||||
) ?? false;
|
||||
if (!canWrite) throw error(403, 'Forbidden');
|
||||
if (!hasWriteAll(locals)) throw error(403, 'Forbidden');
|
||||
|
||||
const { id } = params;
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { fail, redirect } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { createApiClient } from '$lib/shared/api.server';
|
||||
import { parseBackendError, getErrorMessage } from '$lib/shared/errors';
|
||||
import { hasWriteAll } from '$lib/shared/server/permissions';
|
||||
|
||||
export async function load({
|
||||
fetch,
|
||||
@@ -12,11 +13,7 @@ export async function load({
|
||||
locals: App.Locals;
|
||||
url: URL;
|
||||
}) {
|
||||
const canWrite =
|
||||
locals.user?.groups?.some((g: { permissions: string[] }) =>
|
||||
g.permissions.includes('WRITE_ALL')
|
||||
) ?? false;
|
||||
if (!canWrite) throw redirect(303, '/');
|
||||
if (!hasWriteAll(locals)) throw redirect(303, '/');
|
||||
|
||||
const senderId = url.searchParams.get('senderId') || '';
|
||||
const receiverId = url.searchParams.get('receiverId') || '';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { createApiClient } from '$lib/shared/api.server';
|
||||
import { getErrorMessage } from '$lib/shared/errors';
|
||||
import { hasWriteAll } from '$lib/shared/server/permissions';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
@@ -21,10 +22,7 @@ export async function load({ url, fetch, locals }) {
|
||||
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
const canWrite =
|
||||
(locals.user as { groups?: { permissions: string[] }[] } | undefined)?.groups?.some((g) =>
|
||||
g.permissions.includes('WRITE_ALL')
|
||||
) ?? false;
|
||||
const canWrite = hasWriteAll(locals);
|
||||
|
||||
const filters = {
|
||||
q: q || undefined,
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
||||
import { getErrorMessage } from '$lib/shared/errors';
|
||||
import { hasWriteAll } from '$lib/shared/server/permissions';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export async function load({ url, fetch, locals }) {
|
||||
const canWrite =
|
||||
(locals.user as { groups?: { permissions: string[] }[] } | undefined)?.groups?.some((g) =>
|
||||
g.permissions.includes('WRITE_ALL')
|
||||
) ?? false;
|
||||
const canWrite = hasWriteAll(locals);
|
||||
|
||||
const page = Math.max(0, Number.parseInt(url.searchParams.get('page') ?? '0', 10) || 0);
|
||||
const api = createApiClient(fetch);
|
||||
|
||||
Reference in New Issue
Block a user