Merge branch 'workstream-l'
This commit is contained in:
@@ -14,6 +14,11 @@ const navItems = [
|
|||||||
href: "/",
|
href: "/",
|
||||||
label: "Home",
|
label: "Home",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: "building",
|
||||||
|
href: "/dashboard",
|
||||||
|
label: "Organizations",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: "chart",
|
icon: "chart",
|
||||||
href: "/performance",
|
href: "/performance",
|
||||||
@@ -121,6 +126,21 @@ const bottomItems = [
|
|||||||
<path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" stroke-linecap="round" stroke-linejoin="round" />
|
<path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/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}
|
{/if}
|
||||||
|
|
||||||
<!-- Tooltip -->
|
<!-- 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
|
- Race conditions prevented via transaction-scoped existence checks
|
||||||
- Self-demotion guard in `adminUsersUpdate` prevents superusers from removing their own status
|
- 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_
|
_Depends on: J1-J6, C3_
|
||||||
|
|
||||||
- [ ] **L1**: Create `/dashboard` page (org list)
|
- [x] **L1**: Create `/dashboard` page (org list)
|
||||||
- [ ] **L2**: Create `/dashboard/[org]` page (org overview)
|
- [x] **L2**: Create `/dashboard/[slug]` page (org overview)
|
||||||
- [ ] **L3**: Create `/dashboard/[org]/members` page
|
- [x] **L3**: Create `/dashboard/[slug]/members` page
|
||||||
- [ ] **L4**: Create `/dashboard/[org]/settings` page
|
- [x] **L4**: Create `/dashboard/[slug]/settings` page
|
||||||
- [ ] **L5**: Create org invite accept flow
|
- [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)
|
#### 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