Implement Workstream L: Org Pages (Frontend)
Add organization management UI pages: - /dashboard: Org list with grid cards - /dashboard/[slug]: Org overview with stats and previews - /dashboard/[slug]/members: Member management with invites - /dashboard/[slug]/settings: Org settings, leave, delete - /invite/accept: Token-based invite acceptance flow Includes shared org context layout for role detection, reusable components (role-badge, confirm-dialog), and sidebar nav update. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 = [
|
||||
<path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
{/if}
|
||||
{:else if item.icon === "building"}
|
||||
{#if isActive}
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.5 2.25a.75.75 0 000 1.5v16.5h-.75a.75.75 0 000 1.5h16.5a.75.75 0 000-1.5h-.75V3.75a.75.75 0 000-1.5h-15zM9 6a.75.75 0 000 1.5h1.5a.75.75 0 000-1.5H9zm-.75 3.75A.75.75 0 019 9h1.5a.75.75 0 010 1.5H9a.75.75 0 01-.75-.75zM9 12a.75.75 0 000 1.5h1.5a.75.75 0 000-1.5H9zm3.75-5.25A.75.75 0 0113.5 6H15a.75.75 0 010 1.5h-1.5a.75.75 0 01-.75-.75zM13.5 9a.75.75 0 000 1.5H15A.75.75 0 0015 9h-1.5zm-.75 3.75a.75.75 0 01.75-.75H15a.75.75 0 010 1.5h-1.5a.75.75 0 01-.75-.75zM9 19.5v-2.25a.75.75 0 01.75-.75h4.5a.75.75 0 01.75.75v2.25H9z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path d="M3 21h18M5 21V5a2 2 0 012-2h10a2 2 0 012 2v16" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M9 6.5h1.5M9 10h1.5M9 13.5h1.5M13.5 6.5H15M13.5 10H15M13.5 13.5H15M9 21v-4h6v4" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Tooltip -->
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { X } from "@lucide/svelte";
|
||||
import { cn } from "$lib/utils";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: "destructive" | "default";
|
||||
loading?: boolean;
|
||||
onconfirm: () => void;
|
||||
oncancel: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
title,
|
||||
description,
|
||||
confirmLabel = "Confirm",
|
||||
cancelLabel = "Cancel",
|
||||
variant = "default",
|
||||
loading = false,
|
||||
onconfirm,
|
||||
oncancel,
|
||||
}: Props = $props();
|
||||
|
||||
function handleCancel() {
|
||||
open = false;
|
||||
oncancel();
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
onconfirm();
|
||||
}
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Root bind:open>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay
|
||||
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
/>
|
||||
<DialogPrimitive.Content
|
||||
class={cn(
|
||||
"fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2",
|
||||
"rounded-lg border bg-background p-6 shadow-lg",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
"data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]",
|
||||
"data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
|
||||
"duration-200"
|
||||
)}
|
||||
>
|
||||
<!-- Close button -->
|
||||
<DialogPrimitive.Close
|
||||
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
|
||||
onclick={handleCancel}
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="space-y-2">
|
||||
<DialogPrimitive.Title class="text-lg font-semibold leading-none tracking-tight">
|
||||
{title}
|
||||
</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Description class="text-sm text-muted-foreground">
|
||||
{description}
|
||||
</DialogPrimitive.Description>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<Button variant="outline" onclick={handleCancel} disabled={loading}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant={variant === "destructive" ? "destructive" : "default"}
|
||||
onclick={handleConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
{#if loading}
|
||||
<span class="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
|
||||
{/if}
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
2
apps/publisher-dashboard/src/lib/components/org/index.ts
Normal file
2
apps/publisher-dashboard/src/lib/components/org/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as RoleBadge } from "./role-badge.svelte";
|
||||
export { default as ConfirmDialog } from "./confirm-dialog.svelte";
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { Badge, type BadgeVariant } from "$lib/components/ui/badge";
|
||||
|
||||
interface Props {
|
||||
role: "owner" | "admin" | "member";
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { role, class: className }: Props = $props();
|
||||
|
||||
const variants: Record<string, BadgeVariant> = {
|
||||
owner: "default",
|
||||
admin: "secondary",
|
||||
member: "outline",
|
||||
};
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
owner: "Owner",
|
||||
admin: "Admin",
|
||||
member: "Member",
|
||||
};
|
||||
</script>
|
||||
|
||||
<Badge variant={variants[role]} class={className}>
|
||||
{labels[role]}
|
||||
</Badge>
|
||||
125
apps/publisher-dashboard/src/routes/dashboard/+page.svelte
Normal file
125
apps/publisher-dashboard/src/routes/dashboard/+page.svelte
Normal file
@@ -0,0 +1,125 @@
|
||||
<script lang="ts">
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { goto } from "$app/navigation";
|
||||
import { Building2, Loader2, AlertCircle } from "@lucide/svelte";
|
||||
import { api } from "$lib/api/client";
|
||||
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "$lib/components/ui/card";
|
||||
|
||||
/**
|
||||
* Dashboard page - lists all organizations the user is a member of
|
||||
*/
|
||||
|
||||
// Fetch user's organizations
|
||||
const orgsQuery = createQuery(() => ({
|
||||
queryKey: ["orgs"],
|
||||
queryFn: () => api.orgs.list(),
|
||||
}));
|
||||
|
||||
// Redirect to login on auth error
|
||||
$effect(() => {
|
||||
if (orgsQuery.error) {
|
||||
goto("/auth/login?redirect=" + encodeURIComponent(window.location.pathname));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Format date to relative or absolute string
|
||||
*/
|
||||
function formatDate(date: Date): string {
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) return "Today";
|
||||
if (days === 1) return "Yesterday";
|
||||
if (days < 7) return `${days} days ago`;
|
||||
if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
|
||||
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Organizations | Publisher Dashboard</title>
|
||||
</svelte:head>
|
||||
|
||||
<DashboardLayout title="Organizations">
|
||||
<div class="space-y-6">
|
||||
{#if orgsQuery.isPending}
|
||||
<!-- Loading state -->
|
||||
<div class="flex flex-col items-center justify-center py-16">
|
||||
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<p class="mt-4 text-sm text-muted-foreground">Loading organizations...</p>
|
||||
</div>
|
||||
{:else if orgsQuery.error}
|
||||
<!-- Error state -->
|
||||
<div class="flex flex-col items-center justify-center py-16">
|
||||
<AlertCircle class="h-8 w-8 text-destructive" />
|
||||
<p class="mt-4 text-sm text-destructive">
|
||||
{orgsQuery.error instanceof Error ? orgsQuery.error.message : "Failed to load organizations"}
|
||||
</p>
|
||||
</div>
|
||||
{:else if orgsQuery.data && orgsQuery.data.length === 0}
|
||||
<!-- Empty state -->
|
||||
<Card class="border-dashed">
|
||||
<CardContent class="flex flex-col items-center justify-center py-16">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
<Building2 class="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 class="mt-4 text-lg font-semibold">No organizations yet</h3>
|
||||
<p class="mt-2 text-center text-sm text-muted-foreground">
|
||||
You're not a member of any organizations.<br />
|
||||
Ask an admin to invite you, or create a new organization.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else if orgsQuery.data}
|
||||
<!-- Org grid -->
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each orgsQuery.data as org (org.id)}
|
||||
<a
|
||||
href="/dashboard/{org.slug}"
|
||||
class="group block transition-transform hover:scale-[1.02]"
|
||||
>
|
||||
<Card class="h-full transition-colors group-hover:border-primary/50">
|
||||
<CardHeader class="pb-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Logo or placeholder -->
|
||||
{#if org.logoUrl}
|
||||
<img
|
||||
src={org.logoUrl}
|
||||
alt="{org.displayName} logo"
|
||||
class="h-10 w-10 rounded-lg object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary/20 to-primary/10">
|
||||
<Building2 class="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
<CardTitle class="truncate text-base">
|
||||
{org.displayName}
|
||||
</CardTitle>
|
||||
<p class="truncate text-xs text-muted-foreground">
|
||||
{org.slug}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="pt-0">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Created {formatDate(new Date(org.createdAt))}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import { setContext } from "svelte";
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import { api } from "$lib/api/client";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
|
||||
// Get org slug from URL params
|
||||
const slug = $derived(page.params.slug);
|
||||
|
||||
// Fetch current user
|
||||
const userQuery = createQuery(() => ({
|
||||
queryKey: ["me"],
|
||||
queryFn: () => api.me.get(),
|
||||
}));
|
||||
|
||||
// Fetch org members
|
||||
const membersQuery = createQuery(() => ({
|
||||
queryKey: ["org", slug, "members"],
|
||||
queryFn: () => api.orgs.members.list({ slug: slug! }),
|
||||
enabled: !!slug,
|
||||
}));
|
||||
|
||||
// Fetch org sites
|
||||
const sitesQuery = createQuery(() => ({
|
||||
queryKey: ["org", slug, "sites"],
|
||||
queryFn: () => api.orgs.sites.list({ slug: slug! }),
|
||||
enabled: !!slug,
|
||||
}));
|
||||
|
||||
// Calculate current user's role
|
||||
const currentUserRole = $derived.by(() => {
|
||||
const me = userQuery.data;
|
||||
const members = membersQuery.data;
|
||||
if (!me || !members) return null;
|
||||
return members.find((m) => m.userId === me.id)?.role ?? null;
|
||||
});
|
||||
|
||||
// Check if user can manage org (admin or owner)
|
||||
const canManageOrg = $derived(
|
||||
currentUserRole === "owner" || currentUserRole === "admin"
|
||||
);
|
||||
|
||||
// Check if user is owner
|
||||
const isOwner = $derived(currentUserRole === "owner");
|
||||
|
||||
// Loading state
|
||||
const isLoading = $derived(
|
||||
userQuery.isPending || membersQuery.isPending
|
||||
);
|
||||
|
||||
// Error state
|
||||
const error = $derived(
|
||||
!userQuery.error ? membersQuery.error : null
|
||||
);
|
||||
|
||||
// Redirect to login on auth error
|
||||
$effect(() => {
|
||||
if (userQuery.error) {
|
||||
goto("/auth/login?redirect=" + encodeURIComponent(window.location.pathname));
|
||||
}
|
||||
});
|
||||
|
||||
// Provide context to child components
|
||||
setContext("orgContext", {
|
||||
get slug() { return slug; },
|
||||
get userQuery() { return userQuery; },
|
||||
get membersQuery() { return membersQuery; },
|
||||
get sitesQuery() { return sitesQuery; },
|
||||
get currentUserRole() { return currentUserRole; },
|
||||
get canManageOrg() { return canManageOrg; },
|
||||
get isOwner() { return isOwner; },
|
||||
get isLoading() { return isLoading; },
|
||||
get error() { return error; },
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
@@ -0,0 +1,212 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { Building2, Users, Globe, Settings, ChevronRight, Loader2, AlertCircle } from "@lucide/svelte";
|
||||
import { api } from "$lib/api/client";
|
||||
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "$lib/components/ui/card";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { RoleBadge } from "$lib/components/org";
|
||||
|
||||
/**
|
||||
* Org overview page - shows org details, stats, and navigation
|
||||
*/
|
||||
|
||||
// Types from API contract
|
||||
type OrgMemberOutput = Awaited<ReturnType<typeof api.orgs.members.list>>[number];
|
||||
type OrgSiteOutput = Awaited<ReturnType<typeof api.orgs.sites.list>>[number];
|
||||
|
||||
// Get org context from layout
|
||||
const orgContext = getContext<{
|
||||
slug: string;
|
||||
membersQuery: { data: OrgMemberOutput[] | undefined; isPending: boolean };
|
||||
sitesQuery: { data: OrgSiteOutput[] | undefined; isPending: boolean };
|
||||
currentUserRole: "owner" | "admin" | "member" | null;
|
||||
canManageOrg: boolean;
|
||||
isOwner: boolean;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}>("orgContext");
|
||||
|
||||
const slug = $derived(orgContext.slug);
|
||||
const membersData = $derived(orgContext.membersQuery.data);
|
||||
const sitesData = $derived(orgContext.sitesQuery.data);
|
||||
const currentUserRole = $derived(orgContext.currentUserRole);
|
||||
const canManageOrg = $derived(orgContext.canManageOrg);
|
||||
const isLoading = $derived(orgContext.isLoading);
|
||||
const error = $derived(orgContext.error);
|
||||
|
||||
// Fetch org details
|
||||
const orgQuery = createQuery(() => ({
|
||||
queryKey: ["org", slug, "details"],
|
||||
queryFn: () => api.orgs.get({ slug }),
|
||||
enabled: !!slug,
|
||||
}));
|
||||
|
||||
// Computed values
|
||||
const memberCount = $derived(membersData?.length ?? 0);
|
||||
const siteCount = $derived(sitesData?.length ?? 0);
|
||||
const orgName = $derived(orgQuery.data?.displayName ?? slug);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{orgName} | Publisher Dashboard</title>
|
||||
</svelte:head>
|
||||
|
||||
<DashboardLayout title={orgName}>
|
||||
{#if isLoading || orgQuery.isPending}
|
||||
<!-- Loading state -->
|
||||
<div class="flex flex-col items-center justify-center py-16">
|
||||
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<p class="mt-4 text-sm text-muted-foreground">Loading organization...</p>
|
||||
</div>
|
||||
{:else if error || orgQuery.error}
|
||||
{@const displayError = error || orgQuery.error}
|
||||
<!-- Error state -->
|
||||
<div class="flex flex-col items-center justify-center py-16">
|
||||
<AlertCircle class="h-8 w-8 text-destructive" />
|
||||
<p class="mt-4 text-sm text-destructive">
|
||||
{displayError instanceof Error
|
||||
? displayError.message
|
||||
: "Failed to load organization"}
|
||||
</p>
|
||||
<a
|
||||
href="/dashboard"
|
||||
class="mt-4 text-sm text-primary underline underline-offset-4 hover:text-primary/80"
|
||||
>
|
||||
Back to organizations
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<!-- Header with org info -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
{#if orgQuery.data?.logoUrl}
|
||||
<img
|
||||
src={orgQuery.data.logoUrl}
|
||||
alt="{orgName} logo"
|
||||
class="h-16 w-16 rounded-xl object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-xl bg-gradient-to-br from-primary/20 to-primary/10">
|
||||
<Building2 class="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold">{orgName}</h1>
|
||||
<p class="text-sm text-muted-foreground">{slug}</p>
|
||||
{#if currentUserRole}
|
||||
<RoleBadge role={currentUserRole} class="mt-1" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if canManageOrg}
|
||||
<Button variant="outline" href="/dashboard/{slug}/settings">
|
||||
<Settings class="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Stats cards -->
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<a href="/dashboard/{slug}/members" class="group">
|
||||
<Card class="transition-colors group-hover:border-primary/50">
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">Members</CardTitle>
|
||||
<Users class="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-2xl font-bold">{memberCount}</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{memberCount === 1 ? "team member" : "team members"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</a>
|
||||
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">Sites</CardTitle>
|
||||
<Globe class="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-2xl font-bold">{siteCount}</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{siteCount === 1 ? "connected site" : "connected sites"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Quick navigation -->
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<!-- Members section -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<CardTitle class="text-base">Team Members</CardTitle>
|
||||
<a
|
||||
href="/dashboard/{slug}/members"
|
||||
class="flex items-center text-sm text-primary hover:underline"
|
||||
>
|
||||
View all
|
||||
<ChevronRight class="ml-1 h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if membersData && membersData.length > 0}
|
||||
<div class="space-y-3">
|
||||
{#each membersData.slice(0, 5) as member (member.id)}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-primary/20 to-primary/10 text-xs font-medium">
|
||||
{(member.displayName || member.email).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium">
|
||||
{member.displayName || member.email}
|
||||
</p>
|
||||
{#if member.displayName}
|
||||
<p class="text-xs text-muted-foreground">{member.email}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<RoleBadge role={member.role} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground">No members yet</p>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Sites section -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<CardTitle class="text-base">Connected Sites</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if sitesData && sitesData.length > 0}
|
||||
<div class="space-y-3">
|
||||
{#each sitesData.slice(0, 5) as site (site.id)}
|
||||
<div class="flex items-center gap-3">
|
||||
<Globe class="h-4 w-4 text-muted-foreground" />
|
||||
<span class="text-sm">{site.domain}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground">No sites connected</p>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</DashboardLayout>
|
||||
@@ -0,0 +1,397 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { Users, UserPlus, Loader2, AlertCircle, X, Clock } from "@lucide/svelte";
|
||||
import { api } from "$lib/api/client";
|
||||
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "$lib/components/ui/card";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "$lib/components/ui/table";
|
||||
import { RoleBadge, ConfirmDialog } from "$lib/components/org";
|
||||
|
||||
/**
|
||||
* Members management page
|
||||
*/
|
||||
|
||||
// Types from API contract
|
||||
type OrgMemberOutput = Awaited<ReturnType<typeof api.orgs.members.list>>[number];
|
||||
type OrgInviteOutput = Awaited<ReturnType<typeof api.orgs.invites.list>>[number];
|
||||
type UserProfile = Awaited<ReturnType<typeof api.me.get>>;
|
||||
|
||||
// Get org context from layout
|
||||
const orgContext = getContext<{
|
||||
slug: string;
|
||||
userQuery: { data: UserProfile | undefined };
|
||||
membersQuery: { data: OrgMemberOutput[] | undefined; isPending: boolean };
|
||||
currentUserRole: "owner" | "admin" | "member" | null;
|
||||
canManageOrg: boolean;
|
||||
isOwner: boolean;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}>("orgContext");
|
||||
|
||||
const slug = $derived(orgContext.slug);
|
||||
const userData = $derived(orgContext.userQuery.data);
|
||||
const membersData = $derived(orgContext.membersQuery.data);
|
||||
const currentUserRole = $derived(orgContext.currentUserRole);
|
||||
const canManageOrg = $derived(orgContext.canManageOrg);
|
||||
const isOwner = $derived(orgContext.isOwner);
|
||||
const isLoading = $derived(orgContext.isLoading);
|
||||
const error = $derived(orgContext.error);
|
||||
const currentUserId = $derived(userData?.id);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch invites (only for admins+)
|
||||
const invitesQuery = createQuery(() => ({
|
||||
queryKey: ["org", slug, "invites"],
|
||||
queryFn: () => api.orgs.invites.list({ slug }),
|
||||
enabled: !!slug && canManageOrg,
|
||||
}));
|
||||
|
||||
// Invite form state
|
||||
let inviteEmail = $state("");
|
||||
let inviteRole = $state<"member" | "admin" | "owner">("member");
|
||||
let isInviting = $state(false);
|
||||
|
||||
// Confirmation dialog state
|
||||
let confirmDialogOpen = $state(false);
|
||||
let confirmDialogTitle = $state("");
|
||||
let confirmDialogDescription = $state("");
|
||||
let confirmDialogVariant = $state<"default" | "destructive">("destructive");
|
||||
let confirmAction = $state<() => Promise<void>>(() => Promise.resolve());
|
||||
let isConfirmLoading = $state(false);
|
||||
|
||||
/**
|
||||
* Send invite to email
|
||||
*/
|
||||
async function handleInvite() {
|
||||
if (!inviteEmail.trim()) {
|
||||
toast.error("Please enter an email address");
|
||||
return;
|
||||
}
|
||||
|
||||
isInviting = true;
|
||||
try {
|
||||
await api.orgs.invites.create({ slug, email: inviteEmail.trim(), role: inviteRole });
|
||||
toast.success("Invitation sent!");
|
||||
inviteEmail = "";
|
||||
inviteRole = "member";
|
||||
await queryClient.invalidateQueries({ queryKey: ["org", slug, "invites"] });
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to send invitation");
|
||||
} finally {
|
||||
isInviting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a pending invite
|
||||
*/
|
||||
async function handleCancelInvite(inviteId: number, email: string) {
|
||||
confirmDialogTitle = "Cancel Invitation";
|
||||
confirmDialogDescription = `Are you sure you want to cancel the invitation to ${email}?`;
|
||||
confirmDialogVariant = "destructive";
|
||||
confirmAction = async () => {
|
||||
try {
|
||||
await api.orgs.invites.cancel({ slug, inviteId });
|
||||
toast.success("Invitation cancelled");
|
||||
await queryClient.invalidateQueries({ queryKey: ["org", slug, "invites"] });
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to cancel invitation");
|
||||
}
|
||||
};
|
||||
confirmDialogOpen = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update member role
|
||||
*/
|
||||
async function handleUpdateRole(userId: number, newRole: "owner" | "admin" | "member") {
|
||||
try {
|
||||
await api.orgs.members.updateRole({ slug, userId, role: newRole });
|
||||
toast.success("Role updated");
|
||||
await queryClient.invalidateQueries({ queryKey: ["org", slug, "members"] });
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to update role");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove member
|
||||
*/
|
||||
async function handleRemoveMember(userId: number, displayName: string | null, email: string) {
|
||||
confirmDialogTitle = "Remove Member";
|
||||
confirmDialogDescription = `Are you sure you want to remove ${displayName || email} from this organization?`;
|
||||
confirmDialogVariant = "destructive";
|
||||
confirmAction = async () => {
|
||||
try {
|
||||
await api.orgs.members.remove({ slug, userId });
|
||||
toast.success("Member removed");
|
||||
await queryClient.invalidateQueries({ queryKey: ["org", slug, "members"] });
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to remove member");
|
||||
}
|
||||
};
|
||||
confirmDialogOpen = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute confirm action
|
||||
*/
|
||||
async function executeConfirmAction() {
|
||||
isConfirmLoading = true;
|
||||
try {
|
||||
await confirmAction();
|
||||
confirmDialogOpen = false;
|
||||
} finally {
|
||||
isConfirmLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time
|
||||
*/
|
||||
function formatRelativeTime(date: Date): string {
|
||||
const now = new Date();
|
||||
const diff = date.getTime() - now.getTime();
|
||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days < 0) return "Expired";
|
||||
if (days === 0) return "Today";
|
||||
if (days === 1) return "Tomorrow";
|
||||
return `${days} days`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can remove a member
|
||||
*/
|
||||
function canRemoveMember(memberRole: string, memberId: number): boolean {
|
||||
if (memberId === currentUserId) return false; // Can't remove self
|
||||
if (isOwner) return true; // Owners can remove anyone
|
||||
if (currentUserRole === "admin" && memberRole === "member") return true; // Admins can remove members
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available roles for invite based on current user's role
|
||||
*/
|
||||
const availableInviteRoles = $derived.by(() => {
|
||||
if (isOwner) return ["member", "admin", "owner"] as const;
|
||||
if (currentUserRole === "admin") return ["member", "admin"] as const;
|
||||
return ["member"] as const;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Members | Publisher Dashboard</title>
|
||||
</svelte:head>
|
||||
|
||||
<DashboardLayout title="Members">
|
||||
{#if isLoading}
|
||||
<div class="flex flex-col items-center justify-center py-16">
|
||||
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<p class="mt-4 text-sm text-muted-foreground">Loading members...</p>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="flex flex-col items-center justify-center py-16">
|
||||
<AlertCircle class="h-8 w-8 text-destructive" />
|
||||
<p class="mt-4 text-sm text-destructive">
|
||||
{error instanceof Error ? error.message : "Failed to load members"}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<!-- Invite form (admin+ only) -->
|
||||
{#if canManageOrg}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<UserPlus class="h-4 w-4" />
|
||||
Invite Member
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleInvite(); }} class="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<div class="flex-1 space-y-2">
|
||||
<Label for="invite-email">Email address</Label>
|
||||
<Input
|
||||
id="invite-email"
|
||||
type="email"
|
||||
placeholder="colleague@example.com"
|
||||
bind:value={inviteEmail}
|
||||
disabled={isInviting}
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full space-y-2 sm:w-32">
|
||||
<Label for="invite-role">Role</Label>
|
||||
<select
|
||||
id="invite-role"
|
||||
bind:value={inviteRole}
|
||||
disabled={isInviting}
|
||||
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{#each availableInviteRoles as role}
|
||||
<option value={role}>{role.charAt(0).toUpperCase() + role.slice(1)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<Button type="submit" disabled={isInviting || !inviteEmail.trim()}>
|
||||
{#if isInviting}
|
||||
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||
{/if}
|
||||
Send Invite
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<!-- Pending invites (admin+ only) -->
|
||||
{#if canManageOrg && invitesQuery.data && invitesQuery.data.length > 0}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<Clock class="h-4 w-4" />
|
||||
Pending Invitations ({invitesQuery.data.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Invited by</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead class="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each invitesQuery.data as invite (invite.id)}
|
||||
<TableRow>
|
||||
<TableCell class="font-medium">{invite.email}</TableCell>
|
||||
<TableCell><RoleBadge role={invite.role} /></TableCell>
|
||||
<TableCell class="text-muted-foreground">{invite.invitedBy}</TableCell>
|
||||
<TableCell class="text-muted-foreground">
|
||||
{formatRelativeTime(new Date(invite.expiresAt))}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => handleCancelInvite(invite.id, invite.email)}
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
<span class="sr-only">Cancel</span>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<!-- Members list -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<Users class="h-4 w-4" />
|
||||
Members ({membersData?.length ?? 0})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if membersData && membersData.length > 0}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Member</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Joined</TableHead>
|
||||
{#if canManageOrg}
|
||||
<TableHead class="w-[100px]"></TableHead>
|
||||
{/if}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#each membersData as member (member.id)}
|
||||
{@const isCurrentUser = member.userId === currentUserId}
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-primary/20 to-primary/10 text-xs font-medium">
|
||||
{(member.displayName || member.email).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{member.displayName || member.email}
|
||||
{#if isCurrentUser}
|
||||
<span class="ml-1 text-xs text-muted-foreground">(You)</span>
|
||||
{/if}
|
||||
</p>
|
||||
{#if member.displayName}
|
||||
<p class="text-xs text-muted-foreground">{member.email}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{#if isOwner && !isCurrentUser}
|
||||
<select
|
||||
value={member.role}
|
||||
onchange={(e) => handleUpdateRole(member.userId, e.currentTarget.value as "owner" | "admin" | "member")}
|
||||
class="h-7 rounded-md border border-input bg-transparent px-2 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="member">Member</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="owner">Owner</option>
|
||||
</select>
|
||||
{:else}
|
||||
<RoleBadge role={member.role} />
|
||||
{/if}
|
||||
</TableCell>
|
||||
<TableCell class="text-muted-foreground">
|
||||
{new Date(member.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
{#if canManageOrg}
|
||||
<TableCell>
|
||||
{#if canRemoveMember(member.role, member.userId)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-destructive hover:text-destructive"
|
||||
onclick={() => handleRemoveMember(member.userId, member.displayName, member.email)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
{/if}
|
||||
</TableCell>
|
||||
{/if}
|
||||
</TableRow>
|
||||
{/each}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground">No members yet</p>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
</DashboardLayout>
|
||||
|
||||
<!-- Confirmation dialog -->
|
||||
<ConfirmDialog
|
||||
bind:open={confirmDialogOpen}
|
||||
title={confirmDialogTitle}
|
||||
description={confirmDialogDescription}
|
||||
variant={confirmDialogVariant}
|
||||
loading={isConfirmLoading}
|
||||
onconfirm={executeConfirmAction}
|
||||
oncancel={() => confirmDialogOpen = false}
|
||||
/>
|
||||
@@ -0,0 +1,301 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||
import { goto } from "$app/navigation";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { Settings, Loader2, AlertCircle, AlertTriangle, LogOut, Trash2 } from "@lucide/svelte";
|
||||
import { api } from "$lib/api/client";
|
||||
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "$lib/components/ui/card";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
||||
import { ConfirmDialog } from "$lib/components/org";
|
||||
|
||||
/**
|
||||
* Org settings page
|
||||
*/
|
||||
|
||||
// Get org context from layout
|
||||
const orgContext = getContext<{
|
||||
slug: string;
|
||||
currentUserRole: "owner" | "admin" | "member" | null;
|
||||
canManageOrg: boolean;
|
||||
isOwner: boolean;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
}>("orgContext");
|
||||
|
||||
const slug = $derived(orgContext.slug);
|
||||
const currentUserRole = $derived(orgContext.currentUserRole);
|
||||
const canManageOrg = $derived(orgContext.canManageOrg);
|
||||
const isOwner = $derived(orgContext.isOwner);
|
||||
const isLoading = $derived(orgContext.isLoading);
|
||||
const error = $derived(orgContext.error);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch org details
|
||||
const orgQuery = createQuery(() => ({
|
||||
queryKey: ["org", slug, "details"],
|
||||
queryFn: () => api.orgs.get({ slug }),
|
||||
enabled: !!slug,
|
||||
}));
|
||||
|
||||
// Form state
|
||||
let displayName = $state("");
|
||||
let logoUrl = $state("");
|
||||
let isSaving = $state(false);
|
||||
|
||||
// Initialize form when org data loads
|
||||
$effect(() => {
|
||||
if (orgQuery.data) {
|
||||
displayName = orgQuery.data.displayName;
|
||||
logoUrl = orgQuery.data.logoUrl || "";
|
||||
}
|
||||
});
|
||||
|
||||
// Track if form is dirty
|
||||
const isDirty = $derived(
|
||||
orgQuery.data &&
|
||||
(displayName !== orgQuery.data.displayName ||
|
||||
logoUrl !== (orgQuery.data.logoUrl || ""))
|
||||
);
|
||||
|
||||
// Confirmation dialog state
|
||||
let confirmDialogOpen = $state(false);
|
||||
let confirmDialogTitle = $state("");
|
||||
let confirmDialogDescription = $state("");
|
||||
let confirmDialogVariant = $state<"default" | "destructive">("destructive");
|
||||
let confirmDialogConfirmLabel = $state("Confirm");
|
||||
let confirmAction = $state<() => Promise<void>>(() => Promise.resolve());
|
||||
let isConfirmLoading = $state(false);
|
||||
|
||||
/**
|
||||
* Save org settings
|
||||
*/
|
||||
async function handleSave() {
|
||||
if (!canManageOrg) return;
|
||||
|
||||
isSaving = true;
|
||||
try {
|
||||
await api.orgs.update({
|
||||
slug,
|
||||
displayName: displayName.trim(),
|
||||
logoUrl: logoUrl.trim() || undefined,
|
||||
});
|
||||
toast.success("Settings saved");
|
||||
await queryClient.invalidateQueries({ queryKey: ["org", slug, "details"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["orgs"] });
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to save settings");
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave organization
|
||||
*/
|
||||
function handleLeave() {
|
||||
confirmDialogTitle = "Leave Organization";
|
||||
confirmDialogDescription = "Are you sure you want to leave this organization? You will lose access to all resources and will need to be re-invited to rejoin.";
|
||||
confirmDialogVariant = "destructive";
|
||||
confirmDialogConfirmLabel = "Leave Organization";
|
||||
confirmAction = async () => {
|
||||
try {
|
||||
await api.orgs.leave({ slug });
|
||||
toast.success("You have left the organization");
|
||||
await queryClient.invalidateQueries({ queryKey: ["orgs"] });
|
||||
goto("/dashboard");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to leave organization");
|
||||
}
|
||||
};
|
||||
confirmDialogOpen = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete organization
|
||||
*/
|
||||
function handleDelete() {
|
||||
confirmDialogTitle = "Delete Organization";
|
||||
confirmDialogDescription = `Are you sure you want to delete "${displayName}"? This action cannot be undone. All members, invitations, and sites will be permanently deleted.`;
|
||||
confirmDialogVariant = "destructive";
|
||||
confirmDialogConfirmLabel = "Delete Organization";
|
||||
confirmAction = async () => {
|
||||
try {
|
||||
await api.orgs.delete({ slug });
|
||||
toast.success("Organization deleted");
|
||||
await queryClient.invalidateQueries({ queryKey: ["orgs"] });
|
||||
goto("/dashboard");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to delete organization");
|
||||
}
|
||||
};
|
||||
confirmDialogOpen = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute confirm action
|
||||
*/
|
||||
async function executeConfirmAction() {
|
||||
isConfirmLoading = true;
|
||||
try {
|
||||
await confirmAction();
|
||||
confirmDialogOpen = false;
|
||||
} finally {
|
||||
isConfirmLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Settings | Publisher Dashboard</title>
|
||||
</svelte:head>
|
||||
|
||||
<DashboardLayout title="Organization Settings">
|
||||
{#if isLoading || orgQuery.isPending}
|
||||
<div class="flex flex-col items-center justify-center py-16">
|
||||
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<p class="mt-4 text-sm text-muted-foreground">Loading settings...</p>
|
||||
</div>
|
||||
{:else if error || orgQuery.error}
|
||||
{@const displayError = error || orgQuery.error}
|
||||
<div class="flex flex-col items-center justify-center py-16">
|
||||
<AlertCircle class="h-8 w-8 text-destructive" />
|
||||
<p class="mt-4 text-sm text-destructive">
|
||||
{displayError instanceof Error
|
||||
? displayError.message
|
||||
: "Failed to load settings"}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<!-- General Settings (admin+ only) -->
|
||||
{#if canManageOrg}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<Settings class="h-4 w-4" />
|
||||
General Settings
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Update your organization's display name and logo.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSave(); }} class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="display-name">Display Name</Label>
|
||||
<Input
|
||||
id="display-name"
|
||||
type="text"
|
||||
placeholder="My Organization"
|
||||
bind:value={displayName}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="logo-url">Logo URL</Label>
|
||||
<Input
|
||||
id="logo-url"
|
||||
type="url"
|
||||
placeholder="https://example.com/logo.png"
|
||||
bind:value={logoUrl}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Optional. Enter a URL to your organization's logo image.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="submit" disabled={isSaving || !isDirty || !displayName.trim()}>
|
||||
{#if isSaving}
|
||||
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
|
||||
{/if}
|
||||
Save Changes
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<!-- Leave Organization (non-owners only) -->
|
||||
{#if currentUserRole && !isOwner}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base">
|
||||
<LogOut class="h-4 w-4" />
|
||||
Leave Organization
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Remove yourself from this organization.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert class="mb-4 border-amber-500/50 bg-amber-500/10">
|
||||
<AlertTriangle class="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
If you leave, you will lose access to all organization resources. You will need to be re-invited to rejoin.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button variant="outline" onclick={handleLeave}>
|
||||
<LogOut class="mr-2 h-4 w-4" />
|
||||
Leave Organization
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<!-- Danger Zone (owners only) -->
|
||||
{#if isOwner}
|
||||
<Card class="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2 text-base text-destructive">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
Danger Zone
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Irreversible actions that permanently affect your organization.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert variant="destructive" class="mb-4">
|
||||
<AlertTriangle class="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Deleting this organization will permanently remove all members, invitations, and sites. This action cannot be undone.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button variant="destructive" onclick={handleDelete}>
|
||||
<Trash2 class="mr-2 h-4 w-4" />
|
||||
Delete Organization
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<!-- Back link -->
|
||||
<div class="pt-4">
|
||||
<a
|
||||
href="/dashboard/{slug}"
|
||||
class="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
← Back to organization
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</DashboardLayout>
|
||||
|
||||
<!-- Confirmation dialog -->
|
||||
<ConfirmDialog
|
||||
bind:open={confirmDialogOpen}
|
||||
title={confirmDialogTitle}
|
||||
description={confirmDialogDescription}
|
||||
variant={confirmDialogVariant}
|
||||
confirmLabel={confirmDialogConfirmLabel}
|
||||
loading={isConfirmLoading}
|
||||
onconfirm={executeConfirmAction}
|
||||
oncancel={() => confirmDialogOpen = false}
|
||||
/>
|
||||
178
apps/publisher-dashboard/src/routes/invite/accept/+page.svelte
Normal file
178
apps/publisher-dashboard/src/routes/invite/accept/+page.svelte
Normal file
@@ -0,0 +1,178 @@
|
||||
<script lang="ts">
|
||||
import { CheckCircle2, Loader2, UserPlus, XCircle } from "@lucide/svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import { api } from "$lib/api/client";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
|
||||
/**
|
||||
* Org invite acceptance page
|
||||
* Automatically accepts the invite token from URL on mount
|
||||
* Redirects to dashboard on success
|
||||
*/
|
||||
|
||||
let isAccepting = $state(true);
|
||||
let error = $state("");
|
||||
let success = $state(false);
|
||||
|
||||
const token = $derived(page.url.searchParams.get("token"));
|
||||
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
*/
|
||||
async function checkAuth(): Promise<boolean> {
|
||||
try {
|
||||
await api.me.get();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept the invitation with the token from URL
|
||||
*/
|
||||
async function acceptInvite(): Promise<void> {
|
||||
if (!token) {
|
||||
error = "No invitation token provided";
|
||||
isAccepting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user is logged in
|
||||
const isAuthenticated = await checkAuth();
|
||||
if (!isAuthenticated) {
|
||||
// Redirect to login with return URL
|
||||
const returnUrl = `/invite/accept?token=${encodeURIComponent(token)}`;
|
||||
goto(`/auth/login?redirect=${encodeURIComponent(returnUrl)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.orgs.invites.accept({ token });
|
||||
success = true;
|
||||
toast.success("You've joined the organization!");
|
||||
// Redirect to dashboard after a short delay
|
||||
setTimeout(() => {
|
||||
goto("/dashboard");
|
||||
}, 1500);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
// Handle specific error cases
|
||||
if (e.message.includes("expired") || e.message.includes("invalid")) {
|
||||
error = "This invitation has expired or is invalid. Please ask for a new invitation.";
|
||||
} else if (e.message.includes("already") || e.message.includes("member")) {
|
||||
error = "You're already a member of this organization.";
|
||||
} else if (e.message.includes("email") || e.message.includes("mismatch")) {
|
||||
error = "This invitation was sent to a different email address. Please log in with the correct account.";
|
||||
} else {
|
||||
error = e.message;
|
||||
}
|
||||
} else {
|
||||
error = "Failed to accept invitation";
|
||||
}
|
||||
} finally {
|
||||
isAccepting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-accept on mount
|
||||
$effect(() => {
|
||||
if (token) {
|
||||
void acceptInvite();
|
||||
} else {
|
||||
error = "No invitation token provided";
|
||||
isAccepting = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Accept Invitation | Publisher Dashboard</title>
|
||||
<meta name="description" content="Accept your organization invitation" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div class="w-full max-w-md space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="space-y-4 text-center">
|
||||
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||
{#if isAccepting}
|
||||
<UserPlus class="h-8 w-8 animate-pulse text-primary" />
|
||||
{:else if error}
|
||||
<XCircle class="h-8 w-8 text-destructive" />
|
||||
{:else if success}
|
||||
<CheckCircle2 class="h-8 w-8 text-green-600" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">
|
||||
{#if isAccepting}
|
||||
Accepting invitation...
|
||||
{:else if error}
|
||||
Unable to join
|
||||
{:else if success}
|
||||
Welcome aboard!
|
||||
{/if}
|
||||
</h1>
|
||||
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{#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}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
{#if isAccepting}
|
||||
<div class="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
<span>Processing...</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Success message -->
|
||||
{#if success}
|
||||
<div class="flex items-center justify-center gap-2 text-sm text-green-600">
|
||||
<CheckCircle2 class="h-4 w-4" />
|
||||
<span>You've successfully joined the organization!</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error message -->
|
||||
{#if error}
|
||||
<div class="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
|
||||
<p class="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="space-y-3">
|
||||
{#if token}
|
||||
<Button class="h-10 w-full" onclick={acceptInvite}>
|
||||
Try again
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<Button variant="outline" class="h-10 w-full" href="/dashboard">
|
||||
Go to Dashboard
|
||||
</Button>
|
||||
|
||||
<div class="text-center">
|
||||
<a
|
||||
href="/auth/login"
|
||||
class="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
||||
>
|
||||
Sign in with a different account
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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)
|
||||
|
||||
|
||||
258
docs/test-plans/org-dashboard.md
Normal file
258
docs/test-plans/org-dashboard.md
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user