Merge branch 'workstream-m' - Admin panel

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
RevIQ
2026-01-09 18:26:28 +08:00
13 changed files with 1853 additions and 4 deletions

View File

@@ -0,0 +1 @@
export { default as SuperuserBadge } from "./superuser-badge.svelte";

View File

@@ -0,0 +1,9 @@
<script lang="ts">
import { Shield } from "@lucide/svelte";
import { Badge } from "$lib/components/ui/badge/index.js";
</script>
<Badge variant="destructive" class="gap-1">
<Shield class="h-3 w-3" />
Superuser
</Badge>

View File

@@ -1,5 +1,7 @@
<script lang="ts">
import { createQuery } from "@tanstack/svelte-query";
import { page } from "$app/stores";
import { api } from "$lib/api/client.js";
import { cn } from "$lib/utils.js";
interface Props {
@@ -8,6 +10,14 @@ interface Props {
let { class: className }: Props = $props();
// Fetch current user to check superuser status
const userQuery = createQuery(() => ({
queryKey: ["me"],
queryFn: () => api.me.get(),
}));
const isSuperuser = $derived(userQuery.data?.isSuperuser ?? false);
const navItems = [
{
icon: "home",
@@ -38,6 +48,13 @@ const bottomItems = [
label: "Settings",
},
];
// Admin nav item (only shown for superusers)
const adminItem = {
icon: "shield",
href: "/admin",
label: "Admin",
};
</script>
<aside
@@ -152,6 +169,43 @@ const bottomItems = [
</a>
{/each}
<!-- Admin link (superusers only) -->
{#if isSuperuser}
{@const isActive = $page.url.pathname.startsWith(adminItem.href)}
<a
href={adminItem.href}
class={cn(
"group relative flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
isActive
? "bg-destructive/20 text-destructive"
: "text-sidebar-muted hover:bg-destructive/10 hover:text-destructive",
)}
aria-label={adminItem.label}
aria-current={isActive ? "page" : undefined}
>
{#if isActive}
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path
fill-rule="evenodd"
d="M12.516 2.17a.75.75 0 00-1.032 0 11.209 11.209 0 01-7.877 3.08.75.75 0 00-.722.515A12.74 12.74 0 002.25 9.75c0 5.942 4.064 10.933 9.563 12.348a.749.749 0 00.374 0c5.499-1.415 9.563-6.406 9.563-12.348 0-1.39-.223-2.73-.635-3.985a.75.75 0 00-.722-.516l-.143.001c-2.996 0-5.717-1.17-7.734-3.08zm3.094 8.016a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z"
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="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{/if}
<!-- Tooltip -->
<span
class="pointer-events-none absolute left-full ml-3 whitespace-nowrap rounded-md bg-foreground px-2.5 py-1.5 text-xs font-medium text-background opacity-0 shadow-lg transition-all duration-150 group-hover:opacity-100"
>
{adminItem.label}
</span>
</a>
{/if}
<!-- Bottom items -->
<div class="mt-auto flex flex-col items-center gap-3">
{#each bottomItems as item}

View File

@@ -0,0 +1,31 @@
/**
* Date formatting utilities for consistent display across the app
*/
/**
* Format a date for display in tables and lists
* Example: "Jan 15, 2024"
*/
export function formatDate(date: string | Date): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
/**
* Format a date with time for detailed views
* Example: "Jan 15, 2024, 3:30 PM"
*/
export function formatDateTime(date: string | Date): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
}

View File

@@ -0,0 +1,51 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { createQuery } from "@tanstack/svelte-query";
import { setContext } from "svelte";
import { toast } from "svelte-sonner";
import { goto } from "$app/navigation";
import { api } from "$lib/api/client.js";
interface Props {
children: Snippet;
}
let { children }: Props = $props();
// Fetch current user to check superuser status
const userQuery = createQuery(() => ({
queryKey: ["me"],
queryFn: () => api.me.get(),
}));
// Redirect non-superusers
$effect(() => {
if (userQuery.data && !userQuery.data.isSuperuser) {
toast.error("Access denied. Superuser privileges required.");
goto("/dashboard");
}
if (userQuery.error) {
goto(
`/auth/login?redirect=${encodeURIComponent(window.location.pathname)}`,
);
}
});
// Provide admin context to child pages
setContext("adminContext", {
get userQuery() {
return userQuery;
},
get isSuperuser() {
return userQuery.data?.isSuperuser ?? false;
},
get isLoading() {
return userQuery.isPending;
},
get user() {
return userQuery.data;
},
});
</script>
{@render children()}

View File

@@ -0,0 +1,112 @@
<script lang="ts">
import { AlertCircle, Building, Loader2, Plus, Users } from "@lucide/svelte";
import { createQuery } from "@tanstack/svelte-query";
import { api } from "$lib/api/client.js";
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
import { Badge } from "$lib/components/ui/badge/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "$lib/components/ui/card/index.js";
/**
* Admin Dashboard page - overview of admin resources
*/
// Fetch all orgs
const orgsQuery = createQuery(() => ({
queryKey: ["admin", "orgs"],
queryFn: () => api.admin.orgs.list(),
}));
// Fetch all users
const usersQuery = createQuery(() => ({
queryKey: ["admin", "users"],
queryFn: () => api.admin.users.list(),
}));
const isLoading = $derived(orgsQuery.isPending || usersQuery.isPending);
const hasError = $derived(orgsQuery.error || usersQuery.error);
</script>
<svelte:head>
<title>Admin Dashboard | Publisher Dashboard</title>
</svelte:head>
<DashboardLayout title="Admin Dashboard">
<div class="space-y-6">
<!-- Admin badge -->
<div class="flex items-center gap-2">
<Badge variant="destructive">Admin</Badge>
</div>
{#if isLoading}
<!-- 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 admin data...</p>
</div>
{:else if hasError}
<!-- 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">
{hasError instanceof Error ? hasError.message : "Failed to load admin data"}
</p>
</div>
{:else}
<!-- Summary cards -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<!-- Organizations card -->
<a href="/admin/orgs" class="group block transition-transform hover:scale-[1.02]">
<Card class="h-full transition-colors group-hover:border-primary/50">
<CardHeader class="pb-2">
<CardTitle class="flex items-center gap-2 text-base">
<Building class="h-4 w-4" />
Organizations
</CardTitle>
</CardHeader>
<CardContent>
<p class="text-3xl font-bold">{orgsQuery.data?.length ?? 0}</p>
<p class="text-sm text-muted-foreground">Total organizations</p>
</CardContent>
</Card>
</a>
<!-- Users card -->
<a href="/admin/users" class="group block transition-transform hover:scale-[1.02]">
<Card class="h-full transition-colors group-hover:border-primary/50">
<CardHeader class="pb-2">
<CardTitle class="flex items-center gap-2 text-base">
<Users class="h-4 w-4" />
Users
</CardTitle>
</CardHeader>
<CardContent>
<p class="text-3xl font-bold">{usersQuery.data?.length ?? 0}</p>
<p class="text-sm text-muted-foreground">Total users</p>
</CardContent>
</Card>
</a>
</div>
<!-- Quick actions -->
<Card>
<CardHeader>
<CardTitle class="text-base">Quick Actions</CardTitle>
</CardHeader>
<CardContent>
<div class="flex flex-wrap gap-2">
<Button href="/admin/orgs/new">
<Plus class="mr-2 h-4 w-4" />
New Organization
</Button>
</div>
</CardContent>
</Card>
{/if}
</div>
</DashboardLayout>

View File

@@ -0,0 +1,215 @@
<script lang="ts">
import {
AlertCircle,
Building,
Eye,
Loader2,
Plus,
Trash2,
} from "@lucide/svelte";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { api } from "$lib/api/client.js";
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
import ConfirmDialog from "$lib/components/org/confirm-dialog.svelte";
import { Button } from "$lib/components/ui/button/index.js";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "$lib/components/ui/card/index.js";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "$lib/components/ui/table/index.js";
import { formatDate } from "$lib/utils/format-date.js";
/**
* Admin Organizations list page
*/
const queryClient = useQueryClient();
// Fetch all orgs
const orgsQuery = createQuery(() => ({
queryKey: ["admin", "orgs"],
queryFn: () => api.admin.orgs.list(),
}));
// Confirmation dialog state
let confirmDialogOpen = $state(false);
let confirmDialogTitle = $state("");
let confirmDialogDescription = $state("");
let confirmAction = $state<() => Promise<void>>(() => Promise.resolve());
let isConfirmLoading = $state(false);
/**
* Handle delete org action
*/
function handleDelete(slug: string, displayName: string) {
confirmDialogTitle = "Delete Organization";
confirmDialogDescription = `Are you sure you want to delete "${displayName}" (${slug})? This action cannot be undone. All members, invitations, and sites will be permanently deleted.`;
confirmAction = async () => {
try {
await api.admin.orgs.delete({ slug });
toast.success("Organization deleted");
await queryClient.invalidateQueries({ queryKey: ["admin", "orgs"] });
} 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>Organizations | Admin | 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}
<!-- Header -->
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold">
Organizations ({orgsQuery.data.length})
</h2>
<Button href="/admin/orgs/new">
<Plus class="mr-2 h-4 w-4" />
New Organization
</Button>
</div>
{#if 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">
<Building 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">
Create your first organization to get started.
</p>
<Button href="/admin/orgs/new" class="mt-4">
<Plus class="mr-2 h-4 w-4" />
New Organization
</Button>
</CardContent>
</Card>
{:else}
<!-- Organizations table -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Building class="h-4 w-4" />
All Organizations
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Slug</TableHead>
<TableHead>Display Name</TableHead>
<TableHead>Created At</TableHead>
<TableHead class="w-[120px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each orgsQuery.data as org (org.id)}
<TableRow>
<TableCell class="font-mono text-sm">{org.slug}</TableCell>
<TableCell class="font-medium">{org.displayName}</TableCell>
<TableCell class="text-muted-foreground">
{formatDate(org.createdAt)}
</TableCell>
<TableCell>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
href="/dashboard/{org.slug}"
title="View organization"
>
<Eye class="h-4 w-4" />
<span class="sr-only">View</span>
</Button>
<Button
variant="ghost"
size="icon"
class="text-destructive hover:text-destructive"
onclick={() => handleDelete(org.slug, org.displayName)}
title="Delete organization"
>
<Trash2 class="h-4 w-4" />
<span class="sr-only">Delete</span>
</Button>
</div>
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</CardContent>
</Card>
{/if}
<!-- Back link -->
<div class="pt-4">
<a
href="/admin"
class="text-sm text-muted-foreground hover:text-foreground"
>
&larr; Back to admin dashboard
</a>
</div>
{/if}
</div>
</DashboardLayout>
<!-- Confirmation dialog -->
<ConfirmDialog
bind:open={confirmDialogOpen}
title={confirmDialogTitle}
description={confirmDialogDescription}
variant="destructive"
confirmLabel="Delete"
loading={isConfirmLoading}
onconfirm={executeConfirmAction}
oncancel={() => confirmDialogOpen = false}
/>

View File

@@ -0,0 +1,474 @@
<script lang="ts">
import {
AlertCircle,
AlertTriangle,
ArrowLeft,
Building,
Globe,
Loader2,
Plus,
Trash2,
} from "@lucide/svelte";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { api } from "$lib/api/client";
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
import { ConfirmDialog } from "$lib/components/org";
import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Button } from "$lib/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "$lib/components/ui/card";
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 { formatDate } from "$lib/utils/format-date.js";
/**
* Admin organization details page
* Allows superusers to view and manage individual organizations
*/
// Types from API contract
type OrgOutput = Awaited<ReturnType<typeof api.admin.orgs.get>>;
type OrgSiteOutput = Awaited<
ReturnType<typeof api.admin.orgs.listSites>
>[number];
// Get slug from URL params
const slug = $derived(page.params.slug);
const queryClient = useQueryClient();
// Fetch org details
const orgQuery = createQuery(() => ({
queryKey: ["admin", "orgs", slug],
queryFn: () => api.admin.orgs.get({ slug: slug ?? "" }),
enabled: !!slug,
}));
// Fetch sites
const sitesQuery = createQuery(() => ({
queryKey: ["admin", "orgs", slug, "sites"],
queryFn: () => api.admin.orgs.listSites({ slug: slug ?? "" }),
enabled: !!slug,
}));
// Form state
let displayName = $state("");
let logoUrl = $state("");
let newDomain = $state("");
// Loading states
let isSaving = $state(false);
let isAddingSite = $state(false);
// Confirm dialog state
let confirmDialogOpen = $state(false);
let confirmDialogTitle = $state("");
let confirmDialogDescription = $state("");
let confirmDialogVariant = $state<"default" | "destructive">("destructive");
let confirmDialogConfirmLabel = $state("Confirm");
let isConfirmLoading = $state(false);
let pendingAction: (() => Promise<void>) | null = $state(null);
// Initialize form from query data
$effect(() => {
if (orgQuery.data) {
displayName = orgQuery.data.displayName;
logoUrl = orgQuery.data.logoUrl ?? "";
}
});
// Dirty check
const isDirty = $derived(
displayName !== (orgQuery.data?.displayName ?? "") ||
logoUrl !== (orgQuery.data?.logoUrl ?? ""),
);
/**
* Save org settings
*/
async function handleSave() {
if (!(isDirty && slug)) {
return;
}
isSaving = true;
try {
await api.admin.orgs.update({
slug,
displayName: displayName.trim(),
logoUrl: logoUrl.trim() || undefined,
});
toast.success("Organization updated");
await queryClient.invalidateQueries({ queryKey: ["admin", "orgs", slug] });
await queryClient.invalidateQueries({ queryKey: ["admin", "orgs"] });
} catch (e) {
toast.error(
e instanceof Error ? e.message : "Failed to update organization",
);
} finally {
isSaving = false;
}
}
/**
* Add a site to the organization
*/
async function handleAddSite() {
const domain = newDomain.trim();
if (!(domain && slug)) {
return;
}
isAddingSite = true;
try {
await api.admin.orgs.addSite({ slug, domain });
toast.success(`Site "${domain}" added`);
newDomain = "";
await queryClient.invalidateQueries({
queryKey: ["admin", "orgs", slug, "sites"],
});
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to add site");
} finally {
isAddingSite = false;
}
}
/**
* Remove a site from the organization
*/
function handleRemoveSite(domain: string) {
confirmDialogTitle = "Remove Site";
confirmDialogDescription = `Are you sure you want to remove "${domain}" from this organization? This action cannot be undone.`;
confirmDialogVariant = "destructive";
confirmDialogConfirmLabel = "Remove Site";
pendingAction = async () => {
try {
await api.admin.orgs.removeSite({ slug: slug ?? "", domain });
toast.success(`Site "${domain}" removed`);
await queryClient.invalidateQueries({
queryKey: ["admin", "orgs", slug, "sites"],
});
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to remove site");
}
};
confirmDialogOpen = true;
}
/**
* Delete the 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";
pendingAction = async () => {
try {
await api.admin.orgs.delete({ slug: slug ?? "" });
toast.success("Organization deleted");
await queryClient.invalidateQueries({ queryKey: ["admin", "orgs"] });
goto("/admin/orgs");
} catch (e) {
toast.error(
e instanceof Error ? e.message : "Failed to delete organization",
);
}
};
confirmDialogOpen = true;
}
/**
* Execute pending confirm action
*/
async function executeConfirmAction() {
if (!pendingAction) {
return;
}
isConfirmLoading = true;
try {
await pendingAction();
confirmDialogOpen = false;
} finally {
isConfirmLoading = false;
pendingAction = null;
}
}
</script>
<svelte:head>
<title>
{orgQuery.data?.displayName ?? "Organization"} | Admin | Publisher Dashboard
</title>
</svelte:head>
<DashboardLayout title="Organization Details">
{#if 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 organization...</p>
</div>
{:else if 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">
{orgQuery.error instanceof Error
? orgQuery.error.message
: "Failed to load organization"}
</p>
<a
href="/admin/orgs"
class="mt-4 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft class="mr-1 inline h-4 w-4" />
Back to organizations
</a>
</div>
{:else if orgQuery.data}
{@const org = orgQuery.data}
<div class="mx-auto max-w-2xl space-y-6">
<!-- Back link -->
<a
href="/admin/orgs"
class="inline-flex items-center text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft class="mr-1 h-4 w-4" />
Back to organizations
</a>
<!-- Header Section -->
<Card>
<CardHeader>
<div class="flex items-start gap-4">
{#if org.logoUrl}
<img
src={org.logoUrl}
alt="{org.displayName} logo"
class="h-16 w-16 rounded-lg object-cover"
/>
{:else}
<div
class="flex h-16 w-16 items-center justify-center rounded-lg bg-muted"
>
<Building class="h-8 w-8 text-muted-foreground" />
</div>
{/if}
<div class="flex-1">
<CardTitle class="text-2xl">{org.displayName}</CardTitle>
<p class="mt-1 text-sm text-muted-foreground">
Slug: <code class="rounded bg-muted px-1.5 py-0.5">{org.slug}</code>
</p>
<p class="mt-1 text-sm text-muted-foreground">
Created {formatDate(org.createdAt)}
</p>
</div>
</div>
</CardHeader>
</Card>
<!-- Settings Card -->
<Card>
<CardHeader>
<CardTitle class="text-base">Settings</CardTitle>
<CardDescription>
Update the 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="Organization Name"
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 the organization's logo image.
</p>
</div>
</form>
</CardContent>
<CardFooter>
<Button
onclick={handleSave}
disabled={isSaving || !isDirty || !displayName.trim()}
>
{#if isSaving}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{/if}
Save Changes
</Button>
</CardFooter>
</Card>
<!-- Sites Management Card -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Globe class="h-4 w-4" />
Sites ({sitesQuery.data?.length ?? 0})
</CardTitle>
<CardDescription>
Manage the sites associated with this organization.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
{#if sitesQuery.isPending}
<div class="flex items-center justify-center py-4">
<Loader2 class="h-5 w-5 animate-spin text-muted-foreground" />
</div>
{:else if sitesQuery.error}
<Alert variant="destructive">
<AlertCircle class="h-4 w-4" />
<AlertDescription>
{sitesQuery.error instanceof Error
? sitesQuery.error.message
: "Failed to load sites"}
</AlertDescription>
</Alert>
{:else if sitesQuery.data && sitesQuery.data.length > 0}
<Table>
<TableHeader>
<TableRow>
<TableHead>Domain</TableHead>
<TableHead class="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each sitesQuery.data as site (site.id)}
<TableRow>
<TableCell class="font-medium">{site.domain}</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
class="text-destructive hover:text-destructive"
onclick={() => handleRemoveSite(site.domain)}
>
<Trash2 class="mr-1 h-4 w-4" />
Remove
</Button>
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
{:else}
<p class="text-sm text-muted-foreground">No sites configured yet.</p>
{/if}
<!-- Add site form -->
<div class="border-t pt-4">
<form
onsubmit={(e) => {
e.preventDefault();
handleAddSite();
}}
class="flex items-end gap-3"
>
<div class="flex-1 space-y-2">
<Label for="new-domain">Add Site</Label>
<Input
id="new-domain"
type="text"
placeholder="example.com"
bind:value={newDomain}
disabled={isAddingSite}
/>
</div>
<Button
type="submit"
disabled={isAddingSite || !newDomain.trim()}
>
{#if isAddingSite}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{:else}
<Plus class="mr-2 h-4 w-4" />
{/if}
Add
</Button>
</form>
</div>
</CardContent>
</Card>
<!-- Danger Zone Card -->
<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 this 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>
</div>
{/if}
</DashboardLayout>
<!-- Confirmation dialog -->
<ConfirmDialog
bind:open={confirmDialogOpen}
title={confirmDialogTitle}
description={confirmDialogDescription}
variant={confirmDialogVariant}
confirmLabel={confirmDialogConfirmLabel}
loading={isConfirmLoading}
onconfirm={executeConfirmAction}
oncancel={() => {
confirmDialogOpen = false;
pendingAction = null;
}}
/>

View File

@@ -0,0 +1,160 @@
<script lang="ts">
import { ArrowLeft, Loader2 } from "@lucide/svelte";
import { toast } from "svelte-sonner";
import { goto } from "$app/navigation";
import { api } from "$lib/api/client.js";
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
import { Button } from "$lib/components/ui/button/index.js";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "$lib/components/ui/card/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
/**
* Admin New Organization form page
*/
// Form state
let slug = $state("");
let displayName = $state("");
let ownerEmail = $state("");
let isSaving = $state(false);
// Form validation
const isValid = $derived(
slug.trim().length > 0 &&
displayName.trim().length > 0 &&
ownerEmail.trim().length > 0 &&
/^[a-z0-9-]+$/.test(slug.trim()),
);
/**
* Handle form submission
*/
async function handleSubmit() {
if (!isValid || isSaving) {
return;
}
isSaving = true;
try {
await api.admin.orgs.create({
slug: slug.trim(),
displayName: displayName.trim(),
ownerEmail: ownerEmail.trim(),
});
toast.success("Organization created successfully");
goto("/admin/orgs");
} catch (e) {
toast.error(
e instanceof Error ? e.message : "Failed to create organization",
);
} finally {
isSaving = false;
}
}
/**
* Validate slug format on input
*/
function handleSlugInput(event: Event) {
const input = event.target as HTMLInputElement;
// Convert to lowercase and replace invalid characters
input.value = input.value.toLowerCase().replace(/[^a-z0-9-]/g, "");
slug = input.value;
}
</script>
<svelte:head>
<title>New Organization | Admin | Publisher Dashboard</title>
</svelte:head>
<DashboardLayout title="New Organization">
<div class="mx-auto max-w-2xl space-y-6">
<!-- Back link -->
<a
href="/admin/orgs"
class="inline-flex items-center text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft class="mr-1 h-4 w-4" />
Back to organizations
</a>
<!-- Form card -->
<Card>
<CardHeader>
<CardTitle>New Organization</CardTitle>
<CardDescription>
Create a new organization. The owner will receive access automatically.
</CardDescription>
</CardHeader>
<CardContent>
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4">
<!-- Slug field -->
<div class="space-y-2">
<Label for="slug">Slug</Label>
<Input
id="slug"
type="text"
placeholder="my-organization"
value={slug}
oninput={handleSlugInput}
disabled={isSaving}
required
/>
<p class="text-xs text-muted-foreground">
Lowercase letters, numbers, and hyphens only. Used in URLs.
</p>
</div>
<!-- Display Name field -->
<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}
required
/>
<p class="text-xs text-muted-foreground">
The name shown in the dashboard.
</p>
</div>
<!-- Owner Email field -->
<div class="space-y-2">
<Label for="owner-email">Owner Email</Label>
<Input
id="owner-email"
type="email"
placeholder="owner@example.com"
bind:value={ownerEmail}
disabled={isSaving}
required
/>
<p class="text-xs text-muted-foreground">
The email of the user who will own this organization. If the user does not exist, they will be created.
</p>
</div>
<!-- Submit button -->
<div class="pt-4">
<Button type="submit" disabled={!isValid || isSaving}>
{#if isSaving}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{/if}
Create Organization
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
</DashboardLayout>

View File

@@ -0,0 +1,113 @@
<script lang="ts">
import { AlertCircle, Check, Eye, Loader2, Users, X } from "@lucide/svelte";
import { createQuery } from "@tanstack/svelte-query";
import { api } from "$lib/api/client.js";
import { SuperuserBadge } from "$lib/components/admin/index.js";
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
import { Button } from "$lib/components/ui/button/index.js";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "$lib/components/ui/card/index.js";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "$lib/components/ui/table/index.js";
/**
* Admin user list page
* Displays all users in the system with their status
*/
// Fetch all users
const usersQuery = createQuery(() => ({
queryKey: ["admin", "users"],
queryFn: () => api.admin.users.list(),
}));
</script>
<svelte:head>
<title>Users | Admin | Publisher Dashboard</title>
</svelte:head>
<DashboardLayout title="Users">
{#if usersQuery.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 users...</p>
</div>
{:else if usersQuery.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">
{usersQuery.error instanceof Error ? usersQuery.error.message : "Failed to load users"}
</p>
</div>
{:else if usersQuery.data}
<div class="space-y-6">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<Users class="h-4 w-4" />
Users ({usersQuery.data.length})
</CardTitle>
</CardHeader>
<CardContent>
{#if usersQuery.data.length > 0}
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Display Name</TableHead>
<TableHead>Email Verified</TableHead>
<TableHead>Superuser</TableHead>
<TableHead class="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each usersQuery.data as user (user.id)}
<TableRow>
<TableCell class="font-medium">{user.email}</TableCell>
<TableCell class="text-muted-foreground">
{user.displayName ?? "-"}
</TableCell>
<TableCell>
{#if user.emailVerified}
<Check class="h-4 w-4 text-green-600" />
{:else}
<X class="h-4 w-4 text-muted-foreground" />
{/if}
</TableCell>
<TableCell>
{#if user.isSuperuser}
<SuperuserBadge />
{/if}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
href="/admin/users/{encodeURIComponent(user.email)}"
>
<Eye class="mr-1 h-4 w-4" />
View
</Button>
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
{:else}
<p class="text-sm text-muted-foreground">No users found</p>
{/if}
</CardContent>
</Card>
</div>
{/if}
</DashboardLayout>

View File

@@ -0,0 +1,302 @@
<script lang="ts">
import {
AlertCircle,
AlertTriangle,
ArrowLeft,
Check,
Loader2,
Mail,
User,
X,
} from "@lucide/svelte";
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { toast } from "svelte-sonner";
import { page } from "$app/state";
import { api } from "$lib/api/client.js";
import { SuperuserBadge } from "$lib/components/admin/index.js";
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte";
import { Alert, AlertDescription } from "$lib/components/ui/alert/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "$lib/components/ui/card/index.js";
/**
* Admin user details page
* Displays user profile and allows permission management
*/
// Email comes URL-encoded, SvelteKit auto-decodes it
const email = $derived(page.params.email);
// Fetch user details
const userDetailsQuery = createQuery(() => ({
queryKey: ["admin", "users", email],
queryFn: () => api.admin.users.get({ email: email ?? "" }),
enabled: !!email,
}));
// Get current logged-in user to check if viewing self
const meQuery = createQuery(() => ({
queryKey: ["me"],
queryFn: () => api.me.get(),
}));
// Check if viewing self
const isViewingSelf = $derived(
meQuery.data?.email?.toLowerCase() === email?.toLowerCase(),
);
const queryClient = useQueryClient();
// Track superuser toggle state and loading states
let isSuperuser = $state(false);
let hasChanges = $state(false);
let isSaving = $state(false);
let isConfirmingEmail = $state(false);
// Sync state when user data loads
$effect(() => {
if (userDetailsQuery.data) {
isSuperuser = userDetailsQuery.data.isSuperuser;
hasChanges = false;
}
});
// Track changes
$effect(() => {
if (userDetailsQuery.data) {
hasChanges = isSuperuser !== userDetailsQuery.data.isSuperuser;
}
});
/**
* Get initials from display name or email
*/
function getInitials(
name: string | null | undefined,
emailAddr: string,
): string {
if (name) {
const parts = name.split(" ");
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
return emailAddr.slice(0, 2).toUpperCase();
}
/**
* Handle save permissions
*/
async function handleSavePermissions() {
if (!email) {
return;
}
isSaving = true;
try {
await api.admin.users.update({ email, isSuperuser });
toast.success("User permissions updated");
await queryClient.invalidateQueries({
queryKey: ["admin", "users", email],
});
await queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
hasChanges = false;
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to update user");
} finally {
isSaving = false;
}
}
/**
* Handle confirm email
*/
async function handleConfirmEmail() {
if (!email) {
return;
}
isConfirmingEmail = true;
try {
await api.admin.users.confirmEmail({ email });
toast.success("Email confirmed successfully");
await queryClient.invalidateQueries({
queryKey: ["admin", "users", email],
});
await queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to confirm email");
} finally {
isConfirmingEmail = false;
}
}
</script>
<svelte:head>
<title>{userDetailsQuery.data?.displayName ?? email} | Users | Admin</title>
</svelte:head>
<DashboardLayout title="User Details">
<!-- Back navigation -->
<div class="mb-6">
<Button variant="ghost" size="sm" href="/admin/users" class="gap-1">
<ArrowLeft class="h-4 w-4" />
Back to users
</Button>
</div>
{#if userDetailsQuery.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 user...</p>
</div>
{:else if userDetailsQuery.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">
{userDetailsQuery.error instanceof Error
? userDetailsQuery.error.message
: "Failed to load user"}
</p>
</div>
{:else if userDetailsQuery.data}
{@const user = userDetailsQuery.data}
<div class="space-y-6">
<!-- Header Section -->
<Card>
<CardContent class="pt-6">
<div class="flex items-center gap-4">
<div
class="flex h-16 w-16 items-center justify-center rounded-full bg-gradient-to-br from-primary/20 to-primary/10 text-xl font-semibold"
>
{getInitials(user.displayName, user.email)}
</div>
<div class="flex-1">
<div class="flex items-center gap-3">
<h2 class="text-2xl font-semibold">
{user.displayName ?? user.email}
</h2>
{#if user.isSuperuser}
<SuperuserBadge />
{/if}
</div>
<p class="flex items-center gap-1 text-sm text-muted-foreground">
<Mail class="h-3 w-3" />
{user.email}
</p>
</div>
</div>
</CardContent>
</Card>
<!-- Profile Info Card -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2 text-base">
<User class="h-4 w-4" />
Profile Information
</CardTitle>
<CardDescription>Read-only user profile details</CardDescription>
</CardHeader>
<CardContent>
<dl class="grid gap-4 sm:grid-cols-2">
<div>
<dt class="text-sm font-medium text-muted-foreground">Email</dt>
<dd class="mt-1">{user.email}</dd>
</div>
<div>
<dt class="text-sm font-medium text-muted-foreground">Display Name</dt>
<dd class="mt-1">{user.displayName ?? "-"}</dd>
</div>
<div>
<dt class="text-sm font-medium text-muted-foreground">Full Name</dt>
<dd class="mt-1">{user.fullName ?? "-"}</dd>
</div>
<div>
<dt class="text-sm font-medium text-muted-foreground">Phone Number</dt>
<dd class="mt-1">{user.phoneNumber ?? "-"}</dd>
</div>
<div>
<dt class="text-sm font-medium text-muted-foreground">Email Verified</dt>
<dd class="mt-1 flex items-center gap-2">
{#if user.emailVerified}
<Check class="h-4 w-4 text-green-600" />
<span>Yes</span>
{:else}
<X class="h-4 w-4 text-muted-foreground" />
<span>No</span>
{/if}
</dd>
</div>
</dl>
</CardContent>
</Card>
<!-- Permissions Card -->
<Card>
<CardHeader>
<CardTitle class="text-base">Permissions</CardTitle>
<CardDescription>Manage user access levels</CardDescription>
</CardHeader>
<CardContent>
{#if isViewingSelf}
<Alert>
<AlertTriangle class="h-4 w-4" />
<AlertDescription>
You cannot modify your own superuser status. Another superuser must make this
change.
</AlertDescription>
</Alert>
{:else}
<label class="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
checked={isSuperuser}
onchange={(e) => (isSuperuser = e.currentTarget.checked)}
disabled={isViewingSelf || isSaving}
class="h-4 w-4 rounded border-input bg-background text-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
<span class="text-sm font-medium leading-none">Grant superuser privileges</span>
</label>
{/if}
</CardContent>
{#if !isViewingSelf}
<CardFooter>
<Button onclick={handleSavePermissions} disabled={!hasChanges || isSaving}>
{#if isSaving}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{/if}
Save Changes
</Button>
</CardFooter>
{/if}
</Card>
<!-- Actions Card -->
{#if !user.emailVerified}
<Card>
<CardHeader>
<CardTitle class="text-base">Actions</CardTitle>
<CardDescription>Administrative actions for this user</CardDescription>
</CardHeader>
<CardContent>
<Button variant="secondary" onclick={handleConfirmEmail} disabled={isConfirmingEmail}>
{#if isConfirmingEmail}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{/if}
Confirm Email
</Button>
</CardContent>
</Card>
{/if}
</div>
{/if}
</DashboardLayout>