Merge branch 'workstream-l'

This commit is contained in:
RevIQ
2026-01-09 18:18:07 +08:00
12 changed files with 1713 additions and 6 deletions

View File

@@ -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 -->

View File

@@ -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>

View File

@@ -0,0 +1,2 @@
export { default as RoleBadge } from "./role-badge.svelte";
export { default as ConfirmDialog } from "./confirm-dialog.svelte";

View File

@@ -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>

View 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>

View File

@@ -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()}

View File

@@ -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>

View File

@@ -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}
/>

View File

@@ -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"
>
&larr; 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}
/>

View 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>

View File

@@ -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)

View 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