diff --git a/apps/publisher-dashboard/src/lib/components/layout/app-sidebar.svelte b/apps/publisher-dashboard/src/lib/components/layout/app-sidebar.svelte index e6964e4..3c64773 100644 --- a/apps/publisher-dashboard/src/lib/components/layout/app-sidebar.svelte +++ b/apps/publisher-dashboard/src/lib/components/layout/app-sidebar.svelte @@ -14,6 +14,11 @@ const navItems = [ href: "/", label: "Home", }, + { + icon: "building", + href: "/dashboard", + label: "Organizations", + }, { icon: "chart", href: "/performance", @@ -121,6 +126,21 @@ const bottomItems = [ {/if} + {:else if item.icon === "building"} + {#if isActive} + + + + {:else} + + + + + {/if} {/if} diff --git a/apps/publisher-dashboard/src/lib/components/org/confirm-dialog.svelte b/apps/publisher-dashboard/src/lib/components/org/confirm-dialog.svelte new file mode 100644 index 0000000..deaab22 --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/org/confirm-dialog.svelte @@ -0,0 +1,95 @@ + + + + + + + + + + Close + + + +
+ + {title} + + + {description} + +
+ + +
+ + +
+
+
+
diff --git a/apps/publisher-dashboard/src/lib/components/org/index.ts b/apps/publisher-dashboard/src/lib/components/org/index.ts new file mode 100644 index 0000000..1ca1aae --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/org/index.ts @@ -0,0 +1,2 @@ +export { default as RoleBadge } from "./role-badge.svelte"; +export { default as ConfirmDialog } from "./confirm-dialog.svelte"; diff --git a/apps/publisher-dashboard/src/lib/components/org/role-badge.svelte b/apps/publisher-dashboard/src/lib/components/org/role-badge.svelte new file mode 100644 index 0000000..330eef1 --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/org/role-badge.svelte @@ -0,0 +1,26 @@ + + + + {labels[role]} + diff --git a/apps/publisher-dashboard/src/routes/dashboard/+page.svelte b/apps/publisher-dashboard/src/routes/dashboard/+page.svelte new file mode 100644 index 0000000..d2402ed --- /dev/null +++ b/apps/publisher-dashboard/src/routes/dashboard/+page.svelte @@ -0,0 +1,125 @@ + + + + Organizations | Publisher Dashboard + + + +
+ {#if orgsQuery.isPending} + +
+ +

Loading organizations...

+
+ {:else if orgsQuery.error} + +
+ +

+ {orgsQuery.error instanceof Error ? orgsQuery.error.message : "Failed to load organizations"} +

+
+ {:else if orgsQuery.data && orgsQuery.data.length === 0} + + + +
+ +
+

No organizations yet

+

+ You're not a member of any organizations.
+ Ask an admin to invite you, or create a new organization. +

+
+
+ {:else if orgsQuery.data} + + + {/if} +
+
diff --git a/apps/publisher-dashboard/src/routes/dashboard/[slug]/+layout.svelte b/apps/publisher-dashboard/src/routes/dashboard/[slug]/+layout.svelte new file mode 100644 index 0000000..3c6d325 --- /dev/null +++ b/apps/publisher-dashboard/src/routes/dashboard/[slug]/+layout.svelte @@ -0,0 +1,85 @@ + + +{@render children()} diff --git a/apps/publisher-dashboard/src/routes/dashboard/[slug]/+page.svelte b/apps/publisher-dashboard/src/routes/dashboard/[slug]/+page.svelte new file mode 100644 index 0000000..b38b221 --- /dev/null +++ b/apps/publisher-dashboard/src/routes/dashboard/[slug]/+page.svelte @@ -0,0 +1,212 @@ + + + + {orgName} | Publisher Dashboard + + + + {#if isLoading || orgQuery.isPending} + +
+ +

Loading organization...

+
+ {:else if error || orgQuery.error} + {@const displayError = error || orgQuery.error} + +
+ +

+ {displayError instanceof Error + ? displayError.message + : "Failed to load organization"} +

+ + Back to organizations + +
+ {:else} +
+ +
+
+ {#if orgQuery.data?.logoUrl} + {orgName} logo + {:else} +
+ +
+ {/if} +
+

{orgName}

+

{slug}

+ {#if currentUserRole} + + {/if} +
+
+ {#if canManageOrg} + + {/if} +
+ + +
+ + + + Members + + + +
{memberCount}
+

+ {memberCount === 1 ? "team member" : "team members"} +

+
+
+
+ + + + Sites + + + +
{siteCount}
+

+ {siteCount === 1 ? "connected site" : "connected sites"} +

+
+
+
+ + +
+ + + +
+ Team Members + + View all + + +
+
+ + {#if membersData && membersData.length > 0} +
+ {#each membersData.slice(0, 5) as member (member.id)} +
+
+
+ {(member.displayName || member.email).charAt(0).toUpperCase()} +
+
+

+ {member.displayName || member.email} +

+ {#if member.displayName} +

{member.email}

+ {/if} +
+
+ +
+ {/each} +
+ {:else} +

No members yet

+ {/if} +
+
+ + + + +
+ Connected Sites +
+
+ + {#if sitesData && sitesData.length > 0} +
+ {#each sitesData.slice(0, 5) as site (site.id)} +
+ + {site.domain} +
+ {/each} +
+ {:else} +

No sites connected

+ {/if} +
+
+
+
+ {/if} +
diff --git a/apps/publisher-dashboard/src/routes/dashboard/[slug]/members/+page.svelte b/apps/publisher-dashboard/src/routes/dashboard/[slug]/members/+page.svelte new file mode 100644 index 0000000..6db0fd0 --- /dev/null +++ b/apps/publisher-dashboard/src/routes/dashboard/[slug]/members/+page.svelte @@ -0,0 +1,397 @@ + + + + Members | Publisher Dashboard + + + + {#if isLoading} +
+ +

Loading members...

+
+ {:else if error} +
+ +

+ {error instanceof Error ? error.message : "Failed to load members"} +

+
+ {:else} +
+ + {#if canManageOrg} + + + + + Invite Member + + + +
{ e.preventDefault(); handleInvite(); }} class="flex flex-col gap-4 sm:flex-row sm:items-end"> +
+ + +
+
+ + +
+ +
+
+
+ {/if} + + + {#if canManageOrg && invitesQuery.data && invitesQuery.data.length > 0} + + + + + Pending Invitations ({invitesQuery.data.length}) + + + + + + + Email + Role + Invited by + Expires + + + + + {#each invitesQuery.data as invite (invite.id)} + + {invite.email} + + {invite.invitedBy} + + {formatRelativeTime(new Date(invite.expiresAt))} + + + + + + {/each} + +
+
+
+ {/if} + + + + + + + Members ({membersData?.length ?? 0}) + + + + {#if membersData && membersData.length > 0} + + + + Member + Role + Joined + {#if canManageOrg} + + {/if} + + + + {#each membersData as member (member.id)} + {@const isCurrentUser = member.userId === currentUserId} + + +
+
+ {(member.displayName || member.email).charAt(0).toUpperCase()} +
+
+

+ {member.displayName || member.email} + {#if isCurrentUser} + (You) + {/if} +

+ {#if member.displayName} +

{member.email}

+ {/if} +
+
+
+ + {#if isOwner && !isCurrentUser} + + {:else} + + {/if} + + + {new Date(member.createdAt).toLocaleDateString()} + + {#if canManageOrg} + + {#if canRemoveMember(member.role, member.userId)} + + {/if} + + {/if} +
+ {/each} +
+
+ {:else} +

No members yet

+ {/if} +
+
+
+ {/if} +
+ + + confirmDialogOpen = false} +/> diff --git a/apps/publisher-dashboard/src/routes/dashboard/[slug]/settings/+page.svelte b/apps/publisher-dashboard/src/routes/dashboard/[slug]/settings/+page.svelte new file mode 100644 index 0000000..6358e6e --- /dev/null +++ b/apps/publisher-dashboard/src/routes/dashboard/[slug]/settings/+page.svelte @@ -0,0 +1,301 @@ + + + + Settings | Publisher Dashboard + + + + {#if isLoading || orgQuery.isPending} +
+ +

Loading settings...

+
+ {:else if error || orgQuery.error} + {@const displayError = error || orgQuery.error} +
+ +

+ {displayError instanceof Error + ? displayError.message + : "Failed to load settings"} +

+
+ {:else} +
+ + {#if canManageOrg} + + + + + General Settings + + + Update your organization's display name and logo. + + + +
{ e.preventDefault(); handleSave(); }} class="space-y-4"> +
+ + +
+
+ + +

+ Optional. Enter a URL to your organization's logo image. +

+
+ +
+
+
+ {/if} + + + {#if currentUserRole && !isOwner} + + + + + Leave Organization + + + Remove yourself from this organization. + + + + + + + If you leave, you will lose access to all organization resources. You will need to be re-invited to rejoin. + + + + + + {/if} + + + {#if isOwner} + + + + + Danger Zone + + + Irreversible actions that permanently affect your organization. + + + + + + + Deleting this organization will permanently remove all members, invitations, and sites. This action cannot be undone. + + + + + + {/if} + + + +
+ {/if} +
+ + + confirmDialogOpen = false} +/> diff --git a/apps/publisher-dashboard/src/routes/invite/accept/+page.svelte b/apps/publisher-dashboard/src/routes/invite/accept/+page.svelte new file mode 100644 index 0000000..d42b91c --- /dev/null +++ b/apps/publisher-dashboard/src/routes/invite/accept/+page.svelte @@ -0,0 +1,178 @@ + + + + Accept Invitation | Publisher Dashboard + + + +
+
+ +
+
+ {#if isAccepting} + + {:else if error} + + {:else if success} + + {/if} +
+ +
+

+ {#if isAccepting} + Accepting invitation... + {:else if error} + Unable to join + {:else if success} + Welcome aboard! + {/if} +

+ +

+ {#if isAccepting} + Please wait while we process your invitation + {:else if error} + We couldn't process your invitation + {:else if success} + Redirecting to your dashboard... + {/if} +

+
+
+ + + {#if isAccepting} +
+ + Processing... +
+ {/if} + + + {#if success} +
+ + You've successfully joined the organization! +
+ {/if} + + + {#if error} +
+

{error}

+
+ + +
+ {#if token} + + {/if} + + + + +
+ {/if} +
+
diff --git a/docs/initial-app.md b/docs/initial-app.md index 2aae390..e80d00c 100644 --- a/docs/initial-app.md +++ b/docs/initial-app.md @@ -2345,15 +2345,23 @@ _Implementation notes:_ - Race conditions prevented via transaction-scoped existence checks - Self-demotion guard in `adminUsersUpdate` prevents superusers from removing their own status -#### Workstream L: Org Pages (Frontend) +#### Workstream L: Org Pages (Frontend) ✅ _Depends on: J1-J6, C3_ -- [ ] **L1**: Create `/dashboard` page (org list) -- [ ] **L2**: Create `/dashboard/[org]` page (org overview) -- [ ] **L3**: Create `/dashboard/[org]/members` page -- [ ] **L4**: Create `/dashboard/[org]/settings` page -- [ ] **L5**: Create org invite accept flow +- [x] **L1**: Create `/dashboard` page (org list) +- [x] **L2**: Create `/dashboard/[slug]` page (org overview) +- [x] **L3**: Create `/dashboard/[slug]/members` page +- [x] **L4**: Create `/dashboard/[slug]/settings` page +- [x] **L5**: Create `/invite/accept` page (org invite accept flow) + +**Implementation notes:** +- Route param uses `[slug]` to match API contract +- Shared org context via `+layout.svelte` provides role detection (owner/admin/member) +- Role-based UI: owners can manage roles, admins can invite/remove, members view-only +- Confirmation dialogs for destructive actions (remove member, cancel invite, leave/delete org) +- Reusable components: `$lib/components/org/role-badge.svelte`, `confirm-dialog.svelte` +- Sidebar updated with "Organizations" nav item #### Workstream M: Admin Pages (Frontend) diff --git a/docs/test-plans/org-dashboard.md b/docs/test-plans/org-dashboard.md new file mode 100644 index 0000000..f0326d5 --- /dev/null +++ b/docs/test-plans/org-dashboard.md @@ -0,0 +1,258 @@ +# Test Plan: Organization Dashboard (Workstream L) + +## Overview + +Manual UI test plan for organization management pages: +- `/dashboard` - Org list +- `/dashboard/[slug]` - Org overview +- `/dashboard/[slug]/members` - Member management +- `/dashboard/[slug]/settings` - Org settings +- `/invite/accept` - Invite acceptance + +## Prerequisites + +- Dev server running: `bun run --cwd apps/publisher-dashboard dev` +- Test user accounts with different roles in an org (owner, admin, member) +- At least one org with multiple members + +--- + +## 1. Organization List (`/dashboard`) + +### 1.1 Authentication +- [ ] Unauthenticated user visiting `/dashboard` redirects to `/auth/login` +- [ ] After login, user returns to `/dashboard` + +### 1.2 Empty State +- [ ] New user with no orgs sees "You're not a member of any organizations yet" +- [ ] "Create Organization" button is visible and functional + +### 1.3 Org List Display +- [ ] All user's orgs display in a grid +- [ ] Each card shows: org name, slug, logo (or placeholder), created date +- [ ] Cards are clickable and navigate to `/dashboard/[slug]` + +### 1.4 Loading States +- [ ] Loading spinner shows while fetching orgs +- [ ] Error state displays if API fails + +--- + +## 2. Organization Overview (`/dashboard/[slug]`) + +### 2.1 Access Control +- [ ] Non-member visiting org page sees error "Failed to load organization" +- [ ] Member can view org overview + +### 2.2 Header Section +- [ ] Org name displays correctly +- [ ] Org slug displays below name +- [ ] Logo displays if set, placeholder icon if not +- [ ] Current user's role badge shows (Owner/Admin/Member) +- [ ] Settings button visible only for admin/owner + +### 2.3 Stats Cards +- [ ] Members card shows correct count +- [ ] Members card is clickable, navigates to members page +- [ ] Sites card shows correct count + +### 2.4 Team Members Preview +- [ ] Shows up to 5 members with avatar, name/email, role badge +- [ ] "View all" link navigates to members page + +### 2.5 Connected Sites Preview +- [ ] Shows up to 5 sites with domain +- [ ] Empty state if no sites + +--- + +## 3. Members Management (`/dashboard/[slug]/members`) + +### 3.1 View Permissions (All Roles) +- [ ] Members table displays all org members +- [ ] Each row shows: avatar, name, email, role badge, joined date +- [ ] Current user marked with "(You)" + +### 3.2 Invite Form (Admin/Owner Only) +- [ ] Invite form visible for admin and owner +- [ ] Invite form hidden for member role +- [ ] Email input validates email format +- [ ] Role dropdown shows appropriate options: + - Owner: can invite member, admin, owner + - Admin: can invite member, admin only +- [ ] "Send Invite" disabled when email empty +- [ ] Success toast on invite sent +- [ ] Error toast on failure (e.g., user already member) + +### 3.3 Pending Invitations (Admin/Owner Only) +- [ ] Pending invites section visible for admin/owner +- [ ] Hidden for member role +- [ ] Shows: email, role, invited by, expiration +- [ ] Cancel button removes invite with confirmation dialog +- [ ] Cancelled invite disappears from list + +### 3.4 Role Management (Owner Only) +- [ ] Owner sees role dropdown for each member (except self) +- [ ] Admin sees static role badges (no dropdown) +- [ ] Member sees static role badges +- [ ] Changing role updates immediately +- [ ] Success toast on role change + +### 3.5 Remove Member +- [ ] Owner can remove any member (except self) +- [ ] Admin can remove members only (not other admins/owners) +- [ ] Member cannot remove anyone +- [ ] Remove button shows confirmation dialog +- [ ] Removed member disappears from list +- [ ] Cannot remove self (no remove button for current user) + +--- + +## 4. Organization Settings (`/dashboard/[slug]/settings`) + +### 4.1 Access Control +- [ ] Settings page accessible to admin and owner +- [ ] Member role can access but sees limited options + +### 4.2 General Settings (Admin/Owner) +- [ ] Display name input pre-filled with current value +- [ ] Logo URL input pre-filled if set +- [ ] Save button disabled when no changes +- [ ] Save button enabled when form is dirty +- [ ] Success toast on save +- [ ] Changes reflected in org header after save + +### 4.3 Leave Organization (Member/Admin Only) +- [ ] "Leave Organization" section visible for member and admin +- [ ] Hidden for owner (owners cannot leave) +- [ ] Warning alert explains consequences +- [ ] Leave button shows confirmation dialog +- [ ] After leaving, redirects to `/dashboard` +- [ ] User no longer sees org in their list + +### 4.4 Danger Zone (Owner Only) +- [ ] Delete section visible only for owner +- [ ] Hidden for admin and member +- [ ] Warning alert explains permanent deletion +- [ ] Delete button shows confirmation dialog +- [ ] Confirmation describes what will be deleted +- [ ] After delete, redirects to `/dashboard` +- [ ] Org no longer appears for any user + +--- + +## 5. Invite Accept Flow (`/invite/accept`) + +### 5.1 Unauthenticated User +- [ ] Visiting with token redirects to login +- [ ] After login, returns to accept page with token +- [ ] Invite automatically accepted + +### 5.2 Authenticated User - Valid Token +- [ ] Page shows "Accepting invitation..." initially +- [ ] Success message: "You've joined the organization!" +- [ ] Auto-redirects to `/dashboard` after success + +### 5.3 Authenticated User - Invalid/Expired Token +- [ ] Error message: "This invitation has expired or is invalid" +- [ ] "Try again" button visible +- [ ] "Go to Dashboard" button navigates to `/dashboard` +- [ ] "Sign in with a different account" link visible + +### 5.4 Already a Member +- [ ] Error message: "You're already a member of this organization" + +### 5.5 Email Mismatch +- [ ] Error message: "This invitation was sent to a different email address" +- [ ] Suggests logging in with correct account + +### 5.6 No Token +- [ ] Error message: "No invitation token provided" + +--- + +## 6. Navigation & Sidebar + +### 6.1 Sidebar +- [ ] "Organizations" nav item visible in sidebar +- [ ] Building icon displays correctly +- [ ] Clicking navigates to `/dashboard` +- [ ] Active state shows when on `/dashboard` routes + +### 6.2 Breadcrumb Navigation +- [ ] Back links work correctly on settings page +- [ ] "Back to organizations" link on error pages + +--- + +## 7. Cross-Cutting Concerns + +### 7.1 Loading States +- [ ] All pages show loading spinner during data fetch +- [ ] Skeleton states or spinners for async operations + +### 7.2 Error Handling +- [ ] API errors display user-friendly messages +- [ ] Toast notifications for action results +- [ ] Error states don't crash the app + +### 7.3 Responsive Design +- [ ] Pages render correctly on mobile viewport +- [ ] Tables scroll horizontally on small screens +- [ ] Forms stack vertically on mobile + +### 7.4 Query Invalidation +- [ ] After invite: invites list refreshes +- [ ] After role change: members list refreshes +- [ ] After remove member: members list refreshes +- [ ] After org update: org details refresh +- [ ] After leave/delete: orgs list refreshes + +--- + +## 8. Edge Cases + +### 8.1 Last Owner Protection +- [ ] Last owner cannot leave organization +- [ ] Must transfer ownership before leaving + +### 8.2 Self-Actions +- [ ] Cannot remove yourself from org +- [ ] Cannot change your own role (as owner) + +### 8.3 Concurrent Updates +- [ ] UI handles stale data gracefully +- [ ] Refresh shows latest state + +### 8.4 Long Content +- [ ] Long org names truncate or wrap properly +- [ ] Long email addresses don't break layout + +--- + +## Test Matrix: Role-Based Features + +| Feature | Owner | Admin | Member | +|---------|-------|-------|--------| +| View org overview | Yes | Yes | Yes | +| View members list | Yes | Yes | Yes | +| Access settings page | Yes | Yes | Yes | +| Edit org settings | Yes | Yes | No | +| Send invites | Yes | Yes | No | +| Cancel invites | Yes | Yes | No | +| Change member roles | Yes | No | No | +| Remove members | Yes | Members only | No | +| Remove admins | Yes | No | No | +| Leave organization | No | Yes | Yes | +| Delete organization | Yes | No | No | + +--- + +## Regression Checklist + +After any changes to org pages, verify: +- [ ] Existing orgs still load correctly +- [ ] Role detection still works +- [ ] All CRUD operations function +- [ ] Error states still display +- [ ] Navigation works end-to-end