The Angular workspace UI is the entry point of the Task Manager for every user session — the workspace selector, the sidebar navigation, and the member management screens that control who can access what. Building these components closes the loop between the workspace backend (Chapter 25) and the user experience: workspace creation, invitation sending, member list with role badges, and the role change controls that workspace admins use daily. These screens also demonstrate the Angular patterns for asynchronous workflows where multi-step processes (create workspace → invite first member → await acceptance) must remain coherent across navigation.
Workspace UI Component Architecture
| Component | Route | Responsibility |
|---|---|---|
WorkspaceListComponent |
/workspaces | All user’s workspaces — create new button |
WorkspaceCreateComponent |
/workspaces/new | Name, description, slug preview form |
WorkspaceSidebarComponent |
Shell (persistent) | Current workspace switcher + nav links |
WorkspaceMembersComponent |
/w/:slug/members | Member list with roles + invite form |
WorkspaceSettingsComponent |
/w/:slug/settings | Edit name/description, danger zone (delete) |
InvitationAcceptComponent |
/invitations/:token | Accept/decline workspace invitation |
WorkspaceStore signal — loaded once when the workspace guard resolves the slug to a workspace document. All sidebar and header components read from this store signal rather than making separate API calls. When the user switches workspaces (navigating to /w/new-slug), the workspace guard re-runs and updates the store, and all components that read the signal re-render automatically.taskmanager.io/w/acme-corp“. Debounce with a 500ms delay, then call the GET /workspaces/check-slug?slug=acme-corp endpoint to verify availability. Show “✓ Available” or “✗ Already taken — try acme-corp-2”. This eliminates the frustrating “slug already taken” error after form submission.<input placeholder="Type 'acme-corp' to confirm">. Only enable the final delete button when the input matches the workspace slug exactly. This pattern (type to confirm) is the standard UX for irreversible high-stakes actions — GitHub uses it for repository deletion.Complete Workspace UI Implementation
// ── core/stores/workspace.store.ts ────────────────────────────────────────
import { Injectable, signal, computed, inject } from '@angular/core';
import { WorkspaceService } from '../../features/workspaces/services/workspace.service';
import { Workspace, WorkspaceMember, MemberRole } from '@taskmanager/shared';
import { AuthStore } from './auth.store';
@Injectable({ providedIn: 'root' })
export class WorkspaceStore {
private service = inject(WorkspaceService);
private authStore = inject(AuthStore);
private _workspaces = signal<Workspace[]>([]);
private _current = signal<Workspace | null>(null);
private _loading = signal(false);
readonly workspaces = this._workspaces.asReadonly();
readonly current = this._current.asReadonly();
readonly loading = this._loading.asReadonly();
readonly currentMembers = computed(() => this._current()?.members ?? []);
// Current user's role in the current workspace
readonly myRole = computed((): MemberRole | null => {
const ws = this._current();
const userId = this.authStore.user()?._id;
if (!ws || !userId) return null;
return ws.members.find(m => m.userId === userId)?.role ?? null;
});
readonly canManageMembers = computed(() =>
['owner', 'admin'].includes(this.myRole() ?? '')
);
readonly isOwner = computed(() => this.myRole() === 'owner');
loadAll(): void {
this._loading.set(true);
this.service.getMyWorkspaces().subscribe({
next: ws => { this._workspaces.set(ws); this._loading.set(false); },
error: () => this._loading.set(false),
});
}
setCurrent(workspace: Workspace): void {
this._current.set(workspace);
}
addWorkspace(ws: Workspace): void {
this._workspaces.update(list => [ws, ...list]);
this._current.set(ws);
}
updateCurrentMember(userId: string, newRole: MemberRole): void {
this._current.update(ws => {
if (!ws) return ws;
return {
...ws,
members: ws.members.map(m =>
m.userId === userId ? { ...m, role: newRole } : m
),
};
});
}
removeMember(userId: string): void {
this._current.update(ws => {
if (!ws) return ws;
return { ...ws, members: ws.members.filter(m => m.userId !== userId) };
});
}
}
// ── features/workspaces/components/workspace-members.component.ts ──────────
@Component({
selector: 'tm-workspace-members',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReactiveFormsModule, CommonModule, RoleBadgeComponent, AvatarComponent],
template: `
<div class="members-page">
<h2>Members ({{ store.currentMembers().length }})</h2>
<!-- Invite form (admin/owner only) -->
@if (wsStore.canManageMembers()) {
<form [formGroup]="inviteForm" (ngSubmit)="sendInvite()"
class="invite-form">
<input formControlName="email"
type="email"
placeholder="colleague@company.com"
data-testid="invite-email">
<select formControlName="role">
<option value="viewer">Viewer</option>
<option value="member" selected>Member</option>
<option value="admin">Admin</option>
</select>
<button type="submit"
[disabled]="inviteForm.invalid || inviting()"
data-testid="invite-btn">
{{ inviting() ? 'Sending...' : 'Send Invite' }}
</button>
</form>
}
<!-- Member list -->
@for (member of wsStore.currentMembers(); track member.userId) {
<div class="member-row">
<tm-avatar [userId]="member.userId" size="sm"></tm-avatar>
<span class="member-name">{{ getMemberName(member.userId) }}</span>
<tm-role-badge [role]="member.role"></tm-role-badge>
<!-- Role change (admin/owner only, not for self or owner) -->
@if (wsStore.canManageMembers() &&
member.userId !== authStore.user()?._id &&
member.role !== 'owner') {
<select [value]="member.role"
(change)="changeRole(member.userId, $any($event.target).value)">
<option value="viewer">Viewer</option>
<option value="member">Member</option>
@if (wsStore.isOwner()) {
<option value="admin">Admin</option>
}
</select>
<button class="btn btn--danger btn--sm"
(click)="removeMember(member.userId)">
Remove
</button>
}
</div>
}
</div>
`,
})
export class WorkspaceMembersComponent implements OnInit {
private fb = inject(FormBuilder);
private service = inject(WorkspaceService);
private toast = inject(ToastService);
wsStore = inject(WorkspaceStore);
authStore = inject(AuthStore);
inviting = signal(false);
inviteForm = this.fb.group({
email: ['', [Validators.required, Validators.email]],
role: ['member'],
});
ngOnInit(): void {
// Workspace and members already loaded by WorkspaceGuard
}
sendInvite(): void {
if (this.inviteForm.invalid) return;
this.inviting.set(true);
this.service.inviteMember(
this.wsStore.current()!._id,
this.inviteForm.value as { email: string; role: string }
).subscribe({
next: () => {
this.toast.success('Invitation sent');
this.inviteForm.reset({ role: 'member' });
this.inviting.set(false);
},
error: err => {
this.toast.error(err.error?.message ?? 'Failed to send invitation');
this.inviting.set(false);
},
});
}
changeRole(userId: string, newRole: MemberRole): void {
this.service.updateMemberRole(
this.wsStore.current()!._id, userId, newRole
).subscribe({
next: () => {
this.wsStore.updateCurrentMember(userId, newRole);
this.toast.success('Role updated');
},
error: err => this.toast.error(err.error?.message ?? 'Failed to update role'),
});
}
removeMember(userId: string): void {
if (!confirm('Remove this member from the workspace?')) return;
this.service.removeMember(this.wsStore.current()!._id, userId).subscribe({
next: () => {
this.wsStore.removeMember(userId);
this.toast.success('Member removed');
},
error: err => this.toast.error(err.error?.message ?? 'Failed to remove member'),
});
}
getMemberName(userId: string): string {
// In a real app, user details would be populated from a users store or the workspace populate
return userId;
}
}
How It Works
Step 1 — WorkspaceStore Provides Workspace Context to All Components
Setting the current workspace in WorkspaceStore._current from the workspace guard makes the workspace available to every component in the workspace feature without prop drilling or repeated API calls. The sidebar, the member list, the task list, and the settings page all inject WorkspaceStore and read wsStore.current(). The guard runs once per workspace navigation — all components within that workspace share the same loaded object.
Step 2 — Computed Role Signals Drive UI Visibility
canManageMembers and isOwner are computed signals derived from myRole. Template elements bound to these signals show or hide automatically when the workspace changes — if the user switches to a workspace where they are a Viewer, all admin controls disappear without any manual change detection. The role hierarchy is expressed once in computed signals, not scattered across conditional *ngIf checks in multiple templates.
Step 3 — Optimistic Member Updates Avoid Re-fetching the Workspace
After a role change or member removal, the store updates its local _current signal directly (wsStore.updateCurrentMember()) rather than re-fetching the workspace from the API. The template re-renders immediately with the correct new role badge. If the API call fails, an error toast is shown but the optimistic update is not rolled back — for role changes, the user can simply change it again. For removal, a rollback would be more important, but in this implementation a page refresh restores the correct state.
Step 4 — Admin Cannot See the Admin Option in Role Selector
The role change dropdown conditionally shows the Admin option only when wsStore.isOwner() is true. Admins see only Viewer and Member in the selector — they cannot promote anyone to Admin even through the UI. This mirrors the server-side permission check, creating defence in depth: both UI and API enforce the constraint. If a malicious user bypasses the UI and sends an admin-to-admin API request, the server rejects it independently.
Step 5 — Slug Preview Provides Immediate Feedback
Debouncing the workspace name input (350ms), computing the slug client-side, and checking availability via API creates a responsive feedback loop that prevents the most frustrating form failure: discovering a slug is taken after completing a multi-field form. The check endpoint (GET /workspaces/check-slug?slug=x) is fast (just a Workspace.exists({ slug }) query) and does not require authentication, making it safe to call on every debounced keystroke.
Quick Reference
| Task | Code |
|---|---|
| Current workspace | inject(WorkspaceStore).current() |
| User’s role in workspace | wsStore.myRole() — computed from members array |
| Can manage members | wsStore.canManageMembers() — computed from role |
| Update member in store | wsStore.updateCurrentMember(userId, newRole) |
| Remove member from store | wsStore.removeMember(userId) |
| Invite member | workspaceService.inviteMember(wsId, { email, role }) |
| Change role | workspaceService.updateMemberRole(wsId, userId, newRole) |
| Slug availability check | GET /workspaces/check-slug?slug=x (debounced) |