This commit is contained in:
Bastian Wagner
2026-06-10 15:44:18 +02:00
parent 67b5fb8532
commit e1cc78ca27
22 changed files with 749 additions and 26 deletions

View File

@@ -67,5 +67,11 @@ function isApiRequest(request: HttpRequest<unknown>): boolean {
}
function isAuthRequest(request: HttpRequest<unknown>): boolean {
return request.url.startsWith('/api/auth/');
return [
'/api/auth/login',
'/api/auth/register',
'/api/auth/refresh',
'/api/auth/resend-verification',
'/api/auth/verify-email',
].some((publicAuthUrl) => request.url.startsWith(publicAuthUrl));
}

View File

@@ -6,6 +6,12 @@ export interface PublicUser {
onboardingCompleted: boolean;
}
export interface PublicUserSearchResult {
id: string;
email: string;
name?: string;
}
export interface AuthTokenResponse {
accessToken: string;
refreshToken: string;

View File

@@ -5,6 +5,7 @@ import {
AuthTokenResponse,
LoginRequest,
PublicUser,
PublicUserSearchResult,
RegisterRequest,
RegisterResponse,
ResendVerificationResponse,
@@ -53,6 +54,13 @@ export class AuthService {
.pipe(tap((user) => this.storeUser(user)));
}
searchUsers(query: string): Observable<PublicUserSearchResult[]> {
const params = new HttpParams().set('q', query);
return this.http.get<PublicUserSearchResult[]>(`${this.apiUrl}/users/search`, {
params,
});
}
updateOnboardingCompleted(completed: boolean): Observable<PublicUser> {
return this.http
.patch<PublicUser>(`${this.apiUrl}/me/onboarding`, { completed })

View File

@@ -8,7 +8,12 @@
@if (isCreateMode()) {
<p>Liste anlegen</p>
} @else if (list()) {
<p>{{ checkedCount(list()!) }} / {{ list()!.items.length }} erledigt</p>
<p>
{{ checkedCount(list()!) }} / {{ list()!.items.length }} erledigt
@if (list()!.accessRole === 'collaborator') {
- geteilt von {{ list()!.ownerName || list()!.ownerEmail || 'Owner' }}
}
</p>
}
</div>
</header>
@@ -77,6 +82,104 @@
</mat-card-content>
</mat-card>
@if (list() && (showEditor() || list()!.collaborators.length > 0 || list()!.accessRole === 'collaborator')) {
<mat-card class="sharing-card" appearance="outlined">
<mat-card-header>
<mat-card-title>Freigaben</mat-card-title>
<mat-card-subtitle>
@if (canManageShares()) {
{{ list()!.collaborators.length }} Mitwirkende
} @else {
Geteilt von {{ list()!.ownerName || list()!.ownerEmail || 'Owner' }}
}
</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
@if (showShareControls()) {
<mat-form-field appearance="outline" class="share-search-field">
<mat-label>User suchen</mat-label>
<mat-icon matPrefix aria-hidden="true">person_search</mat-icon>
<input
matInput
type="search"
[value]="shareSearchTerm()"
(input)="searchShareUsers($any($event.target).value)"
autocomplete="off"
/>
@if (searchingUsers()) {
<mat-progress-spinner matSuffix mode="indeterminate" diameter="18" />
}
</mat-form-field>
@if (availableShareSearchResults().length > 0) {
<ul class="share-results">
@for (user of availableShareSearchResults(); track user.id) {
<li>
<span>{{ displayUser(user) }}</span>
<button
mat-stroked-button
type="button"
[disabled]="sharingUserId() === user.id"
(click)="shareWithUser(user)"
>
@if (sharingUserId() === user.id) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">person_add</mat-icon>
}
Hinzufuegen
</button>
</li>
}
</ul>
} @else if (shareSearchTerm().trim().length >= 2 && !searchingUsers()) {
<div class="inline-empty">
<mat-icon aria-hidden="true">person_off</mat-icon>
<span>Keine passenden User gefunden.</span>
</div>
}
}
@if (list()!.collaborators.length > 0) {
<ul class="collaborator-list">
@for (collaborator of list()!.collaborators; track collaborator.id) {
<li>
<div>
<strong>{{ collaborator.name || collaborator.email }}</strong>
@if (collaborator.name) {
<span>{{ collaborator.email }}</span>
}
</div>
@if (showShareControls()) {
<button
mat-icon-button
type="button"
[attr.aria-label]="displayUser(collaborator) + ' entfernen'"
[disabled]="removingShareUserId() === collaborator.id"
(click)="removeCollaborator(collaborator.id)"
>
@if (removingShareUserId() === collaborator.id) {
<mat-progress-spinner mode="indeterminate" diameter="18" />
} @else {
<mat-icon aria-hidden="true">person_remove</mat-icon>
}
</button>
}
</li>
}
</ul>
} @else {
<div class="inline-empty">
<mat-icon aria-hidden="true">group</mat-icon>
<span>Noch keine Mitwirkenden.</span>
</div>
}
</mat-card-content>
</mat-card>
}
<mat-card class="items-card" appearance="outlined">
<mat-card-header>
<mat-card-title>Items</mat-card-title>

View File

@@ -34,6 +34,7 @@
.state-card,
.editor-card,
.sharing-card,
.items-card {
min-width: 0;
max-width: 100%;
@@ -50,6 +51,56 @@
gap: 0.75rem;
}
.share-search-field {
width: 100%;
}
.share-results,
.collaborator-list {
display: grid;
gap: 0.5rem;
margin: 0.75rem 0 0;
padding: 0;
list-style: none;
}
.share-results li,
.collaborator-list li {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.75rem;
min-width: 0;
padding: 0.6rem;
border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent);
border-radius: 8px;
background: color-mix(in srgb, var(--mat-sys-surface-container-low) 36%, var(--mat-sys-surface));
}
.share-results span,
.collaborator-list strong,
.collaborator-list span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.collaborator-list div {
display: grid;
min-width: 0;
}
.collaborator-list span {
color: var(--mat-sys-on-surface-variant);
font-size: 0.85rem;
}
.share-results mat-progress-spinner,
.collaborator-list mat-progress-spinner {
display: inline-flex;
margin-right: 0.5rem;
}
.editor-card mat-card-header button {
flex: 0 0 auto;
}

View File

@@ -12,7 +12,9 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { AuthService } from '../../auth/auth.service';
import { getAuthErrorMessage } from '../../auth/error-message';
import { PublicUserSearchResult } from '../../auth/auth.models';
import { OnboardingService } from '../../onboarding/onboarding.service';
import { ListRealtimeEvent, UserList, UserListItem } from '../lists.models';
import { ListsRealtimeService } from '../lists-realtime.service';
@@ -39,6 +41,7 @@ import { ListsService } from '../lists.service';
export class ListDetailComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly formBuilder = inject(NonNullableFormBuilder);
private readonly authService = inject(AuthService);
private readonly listsService = inject(ListsService);
private readonly listsRealtimeService = inject(ListsRealtimeService);
private readonly route = inject(ActivatedRoute);
@@ -54,7 +57,28 @@ export class ListDetailComponent implements OnInit {
protected readonly addingItem = signal(false);
protected readonly errorMessage = signal<string | null>(null);
protected readonly updatingItemId = signal<string | null>(null);
protected readonly shareSearchTerm = signal('');
protected readonly shareSearchResults = signal<PublicUserSearchResult[]>([]);
protected readonly searchingUsers = signal(false);
protected readonly sharingUserId = signal<string | null>(null);
protected readonly removingShareUserId = signal<string | null>(null);
protected readonly canEditItems = computed(() => Boolean(this.list()?.id));
protected readonly canManageShares = computed(
() => this.list()?.accessRole === 'owner' && !this.isCreateMode(),
);
protected readonly showShareControls = computed(
() => this.canManageShares() && this.showEditor(),
);
protected readonly availableShareSearchResults = computed(() => {
const list = this.list();
const collaboratorIds = new Set(
list?.collaborators.map((collaborator) => collaborator.id) ?? [],
);
return this.shareSearchResults().filter(
(user) => user.id !== list?.ownerId && !collaboratorIds.has(user.id),
);
});
protected readonly showEditor = computed(() => this.isCreateMode() || this.editing());
protected readonly listForm = this.formBuilder.group({
@@ -234,6 +258,77 @@ export class ListDetailComponent implements OnInit {
return list.items.filter((item) => item.checked).length;
}
protected searchShareUsers(term: string): void {
this.shareSearchTerm.set(term);
if (term.trim().length < 2) {
this.shareSearchResults.set([]);
return;
}
this.searchingUsers.set(true);
this.authService
.searchUsers(term)
.pipe(finalize(() => this.searchingUsers.set(false)))
.subscribe({
next: (users) => this.shareSearchResults.set(users),
error: (error: unknown) => {
this.shareSearchResults.set([]);
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
protected shareWithUser(user: PublicUserSearchResult): void {
const listId = this.listId();
if (!listId || this.sharingUserId()) {
return;
}
this.sharingUserId.set(user.id);
this.listsService
.shareList(listId, user.id)
.pipe(finalize(() => this.sharingUserId.set(null)))
.subscribe({
next: (list) => {
this.setList(list, !this.showEditor());
this.shareSearchTerm.set('');
this.shareSearchResults.set([]);
this.snackBar.open('Liste geteilt.', 'OK', { duration: 2500 });
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
protected removeCollaborator(userId: string): void {
const listId = this.listId();
if (!listId || this.removingShareUserId()) {
return;
}
this.removingShareUserId.set(userId);
this.listsService
.removeShare(listId, userId)
.pipe(finalize(() => this.removingShareUserId.set(null)))
.subscribe({
next: (list) => {
this.setList(list, !this.showEditor());
this.snackBar.open('Freigabe entfernt.', 'OK', { duration: 2500 });
},
error: (error: unknown) => {
this.snackBar.open(getAuthErrorMessage(error), 'OK', { duration: 5000 });
},
});
}
protected displayUser(user: { name?: string; email: string }): string {
return user.name ? `${user.name} (${user.email})` : user.email;
}
protected async backToLists(): Promise<void> {
await this.router.navigateByUrl('/lists');
}

View File

@@ -124,6 +124,9 @@
<mat-card-title>{{ list.name }}</mat-card-title>
<mat-card-subtitle>
{{ kindLabel(list.kind) }} - {{ progressLabel(list) }}
@if (list.accessRole === 'collaborator') {
- geteilt von {{ list.ownerName || list.ownerEmail || 'Owner' }}
}
</mat-card-subtitle>
</mat-card-header>
@@ -141,6 +144,12 @@
<mat-icon aria-hidden="true">schedule</mat-icon>
{{ list.updatedAt | date: 'dd.MM.yyyy' }}
</span>
@if (list.collaborators.length > 0) {
<span>
<mat-icon aria-hidden="true">group</mat-icon>
{{ list.collaborators.length }} Mitwirkende
</span>
}
</div>
@if (list.items.length > 0) {

View File

@@ -16,14 +16,27 @@ export interface UserListItem {
updatedAt: string;
}
export type UserListAccessRole = 'owner' | 'collaborator';
export interface UserListCollaborator {
id: string;
name?: string;
email: string;
role: 'collaborator';
}
export interface UserList {
id: string;
ownerId: string;
ownerName?: string;
ownerEmail?: string;
accessRole: UserListAccessRole;
sourceTemplateId?: string;
name: string;
description?: string;
kind: ListTemplateKind;
items: UserListItem[];
collaborators: UserListCollaborator[];
createdAt: string;
updatedAt: string;
}

View File

@@ -30,6 +30,14 @@ export class ListsService {
return this.http.patch<UserList>(`${this.apiUrl}/${listId}`, data);
}
shareList(listId: string, userId: string): Observable<UserList> {
return this.http.post<UserList>(`${this.apiUrl}/${listId}/shares`, { userId });
}
removeShare(listId: string, userId: string): Observable<UserList> {
return this.http.delete<UserList>(`${this.apiUrl}/${listId}/shares/${userId}`);
}
addItem(listId: string, data: AddListItemRequest): Observable<UserList> {
return this.http.post<UserList>(`${this.apiUrl}/${listId}/items`, data);
}