diff --git a/apps/publisher-dashboard/src/lib/components/layout/dashboard/app-sidebar.svelte b/apps/publisher-dashboard/src/lib/components/layout/dashboard/app-sidebar.svelte index 051aef0..0dd45dc 100644 --- a/apps/publisher-dashboard/src/lib/components/layout/dashboard/app-sidebar.svelte +++ b/apps/publisher-dashboard/src/lib/components/layout/dashboard/app-sidebar.svelte @@ -1,4 +1,5 @@ + + +
+ + + + +
+ {@render children()} +
+
+
diff --git a/apps/publisher-dashboard/src/routes/dashboard/[slug]/settings/+page.svelte b/apps/publisher-dashboard/src/routes/dashboard/[slug]/settings/+page.svelte index 0a58f1a..7065f54 100644 --- a/apps/publisher-dashboard/src/routes/dashboard/[slug]/settings/+page.svelte +++ b/apps/publisher-dashboard/src/routes/dashboard/[slug]/settings/+page.svelte @@ -12,7 +12,7 @@ import { getContext } from "svelte"; import { toast } from "svelte-sonner"; import { goto } from "$app/navigation"; import { api } from "$lib/api/client"; -import { DashboardLayout } from "$lib/components/layout"; +import { SettingsLayout } from "$lib/components/layout"; import { ConfirmDialog } from "$lib/components/org"; import { Alert, AlertDescription } from "$lib/components/ui/alert"; import { Button } from "$lib/components/ui/button"; @@ -175,7 +175,7 @@ async function executeConfirmAction() { Settings | Publisher Dashboard - + {#if isLoading || orgQuery.isPending}
@@ -192,7 +192,7 @@ async function executeConfirmAction() {

{:else} -
+
{#if canManageOrg} @@ -295,18 +295,9 @@ async function executeConfirmAction() { {/if} - -
{/if} - + +import { + AlertCircle, + Clock, + Loader2, + UserPlus, + Users, + X, +} from "@lucide/svelte"; +import { createQuery, useQueryClient } from "@tanstack/svelte-query"; +import { getContext } from "svelte"; +import { toast } from "svelte-sonner"; +import { api } from "$lib/api/client"; +import { SettingsLayout } from "$lib/components/layout"; +import { ConfirmDialog, RoleBadge } from "$lib/components/org"; +import { Button } from "$lib/components/ui/button"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "$lib/components/ui/card"; +import { Input } from "$lib/components/ui/input"; +import { Label } from "$lib/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from "$lib/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "$lib/components/ui/table"; + +/** + * Members management settings page + */ + +// Types from API contract +type OrgMemberOutput = Awaited< + ReturnType +>[number]; +type OrgInviteOutput = Awaited< + ReturnType +>[number]; +type UserProfile = Awaited>; + +// 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>(() => 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; + if (isOwner) return true; + if (currentUserRole === "admin" && memberRole === "member") return true; + 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; +}); + + + + Members | Publisher Dashboard + + + + {#if isLoading} +
+ +

Loading members...

+
+ {:else if error} +
+ +

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

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

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

+ {#if member.displayName} +

{member.email}

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

No members yet

+ {/if} +
+
+
+ {/if} +
+ + + confirmDialogOpen = false} +/> diff --git a/apps/publisher-dashboard/src/routes/dashboard/[slug]/settings/sites/+page.svelte b/apps/publisher-dashboard/src/routes/dashboard/[slug]/settings/sites/+page.svelte new file mode 100644 index 0000000..919f1db --- /dev/null +++ b/apps/publisher-dashboard/src/routes/dashboard/[slug]/settings/sites/+page.svelte @@ -0,0 +1,40 @@ + + + + Sites | Publisher Dashboard + + + + + + + + Sites + + + Manage your connected websites and domains. + + + +
+
+ +
+

Coming Soon

+

+ Site management features are currently in development. +

+
+
+
+