Implement Workstream M: Admin Pages (Frontend)
Add superuser admin interface for managing organizations and users: - Admin layout with access control (redirects non-superusers) - Admin dashboard with org/user counts and quick actions - Org management: list, create, view/edit details, manage sites - User management: list, view details, toggle superuser, confirm email - SuperuserBadge component for consistent superuser indication - Sidebar shows admin link (shield icon) for superusers only - Centralized date formatting utility at $lib/utils/format-date.ts - Test plan documentation at docs/test-plans/admin.md Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,8 @@
|
||||
* Admin procedure helpers - shared transformation functions
|
||||
*/
|
||||
|
||||
import type { OrgSites, Orgs, Users } from "@reviq/db-schema";
|
||||
import type { Selectable } from "kysely";
|
||||
import type { Orgs, OrgSites, Users } from "@reviq/db-schema";
|
||||
|
||||
/** Transform org record to API response format */
|
||||
export const toOrgResponse = (org: Selectable<Orgs>) => ({
|
||||
|
||||
@@ -44,7 +44,7 @@ export const adminUsersCreate = os.admin.users.create
|
||||
.insertInto("users")
|
||||
.values({
|
||||
email: normalizedEmail,
|
||||
display_name: name || null,
|
||||
display_name: name ?? null,
|
||||
})
|
||||
.returning(["id"])
|
||||
.executeTakeFirstOrThrow();
|
||||
@@ -55,7 +55,7 @@ export const adminUsersCreate = os.admin.users.create
|
||||
.values({
|
||||
org_id: orgId,
|
||||
user_id: newUser.id,
|
||||
role: orgRole || "member",
|
||||
role: orgRole ?? "member",
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -27,10 +27,7 @@ export const adminUsersUpdate = os.admin.users.update
|
||||
}
|
||||
|
||||
// Prevent superuser from demoting themselves
|
||||
if (
|
||||
isSuperuser === false &&
|
||||
normalizedEmail === context.user.email.toLowerCase()
|
||||
) {
|
||||
if (!isSuperuser && normalizedEmail === context.user.email.toLowerCase()) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Cannot remove your own superuser status",
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { adminRoutes } from "./procedures/admin/_routes.js";
|
||||
import { createLoginRequest as createLoginRequestHandler } from "./procedures/auth/create-login-request.js";
|
||||
import { forgotPassword as forgotPasswordHandler } from "./procedures/auth/forgot-password.js";
|
||||
import { loginIfRequestIsCompleted as loginIfRequestIsCompletedHandler } from "./procedures/auth/login-if-completed.js";
|
||||
@@ -9,8 +10,11 @@ import { resendVerificationEmail as resendVerificationHandler } from "./procedur
|
||||
import { resetPassword as resetPasswordHandler } from "./procedures/auth/reset-password.js";
|
||||
import { signup as signupHandler } from "./procedures/auth/signup.js";
|
||||
import { verifyEmail as verifyEmailHandler } from "./procedures/auth/verify-email.js";
|
||||
import { authMiddleware, loginRequestMiddleware, os } from "./procedures/base.js";
|
||||
import { adminRoutes } from "./procedures/admin/_routes.js";
|
||||
import {
|
||||
authMiddleware,
|
||||
loginRequestMiddleware,
|
||||
os,
|
||||
} from "./procedures/base.js";
|
||||
import { meDelete } from "./procedures/me/delete.js";
|
||||
import {
|
||||
getDeviceInfo,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as SuperuserBadge } from "./superuser-badge.svelte";
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { X } from "@lucide/svelte";
|
||||
import { cn } from "$lib/utils";
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { default as RoleBadge } from "./role-badge.svelte";
|
||||
export { default as ConfirmDialog } from "./confirm-dialog.svelte";
|
||||
export { default as RoleBadge } from "./role-badge.svelte";
|
||||
|
||||
31
apps/publisher-dashboard/src/lib/utils/format-date.ts
Normal file
31
apps/publisher-dashboard/src/lib/utils/format-date.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
51
apps/publisher-dashboard/src/routes/admin/+layout.svelte
Normal file
51
apps/publisher-dashboard/src/routes/admin/+layout.svelte
Normal 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()}
|
||||
112
apps/publisher-dashboard/src/routes/admin/+page.svelte
Normal file
112
apps/publisher-dashboard/src/routes/admin/+page.svelte
Normal 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>
|
||||
215
apps/publisher-dashboard/src/routes/admin/orgs/+page.svelte
Normal file
215
apps/publisher-dashboard/src/routes/admin/orgs/+page.svelte
Normal 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"
|
||||
>
|
||||
← 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}
|
||||
/>
|
||||
@@ -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;
|
||||
}}
|
||||
/>
|
||||
160
apps/publisher-dashboard/src/routes/admin/orgs/new/+page.svelte
Normal file
160
apps/publisher-dashboard/src/routes/admin/orgs/new/+page.svelte
Normal 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>
|
||||
113
apps/publisher-dashboard/src/routes/admin/users/+page.svelte
Normal file
113
apps/publisher-dashboard/src/routes/admin/users/+page.svelte
Normal 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>
|
||||
@@ -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>
|
||||
@@ -1,10 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { AlertCircle, Building2, Loader2 } from "@lucide/svelte";
|
||||
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";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "$lib/components/ui/card";
|
||||
|
||||
/**
|
||||
* Dashboard page - lists all organizations the user is a member of
|
||||
@@ -19,7 +24,9 @@ const orgsQuery = createQuery(() => ({
|
||||
// Redirect to login on auth error
|
||||
$effect(() => {
|
||||
if (orgsQuery.error) {
|
||||
goto("/auth/login?redirect=" + encodeURIComponent(window.location.pathname));
|
||||
goto(
|
||||
`/auth/login?redirect=${encodeURIComponent(window.location.pathname)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -31,10 +38,18 @@ function formatDate(date: Date): string {
|
||||
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`;
|
||||
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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import { setContext } from "svelte";
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { setContext } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import { api } from "$lib/api/client";
|
||||
@@ -24,14 +24,14 @@ const userQuery = createQuery(() => ({
|
||||
// Fetch org members
|
||||
const membersQuery = createQuery(() => ({
|
||||
queryKey: ["org", slug, "members"],
|
||||
queryFn: () => api.orgs.members.list({ slug: slug! }),
|
||||
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! }),
|
||||
queryFn: () => api.orgs.sites.list({ slug: slug ?? "" }),
|
||||
enabled: !!slug,
|
||||
}));
|
||||
|
||||
@@ -39,46 +39,64 @@ const sitesQuery = createQuery(() => ({
|
||||
const currentUserRole = $derived.by(() => {
|
||||
const me = userQuery.data;
|
||||
const members = membersQuery.data;
|
||||
if (!me || !members) return null;
|
||||
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"
|
||||
currentUserRole === "owner" || currentUserRole === "admin",
|
||||
);
|
||||
|
||||
// Check if user is owner
|
||||
const isOwner = $derived(currentUserRole === "owner");
|
||||
|
||||
// Loading state
|
||||
const isLoading = $derived(
|
||||
userQuery.isPending || membersQuery.isPending
|
||||
);
|
||||
const isLoading = $derived(userQuery.isPending || membersQuery.isPending);
|
||||
|
||||
// Error state
|
||||
const error = $derived(
|
||||
!userQuery.error ? membersQuery.error : null
|
||||
);
|
||||
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));
|
||||
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; },
|
||||
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>
|
||||
|
||||
|
||||
@@ -1,19 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import {
|
||||
AlertCircle,
|
||||
Building2,
|
||||
ChevronRight,
|
||||
Globe,
|
||||
Loader2,
|
||||
Settings,
|
||||
Users,
|
||||
} from "@lucide/svelte";
|
||||
import { createQuery } from "@tanstack/svelte-query";
|
||||
import { Building2, Users, Globe, Settings, ChevronRight, Loader2, AlertCircle } from "@lucide/svelte";
|
||||
import { getContext } from "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";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "$lib/components/ui/card";
|
||||
|
||||
/**
|
||||
* Org overview page - shows org details, stats, and navigation
|
||||
*/
|
||||
|
||||
// Types from API contract
|
||||
type OrgMemberOutput = Awaited<ReturnType<typeof api.orgs.members.list>>[number];
|
||||
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
|
||||
|
||||
@@ -1,24 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
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 { 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 { 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "$lib/components/ui/table";
|
||||
import { RoleBadge, ConfirmDialog } from "$lib/components/org";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "$lib/components/ui/table";
|
||||
|
||||
/**
|
||||
* 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 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
|
||||
@@ -76,7 +99,11 @@ async function handleInvite() {
|
||||
|
||||
isInviting = true;
|
||||
try {
|
||||
await api.orgs.invites.create({ slug, email: inviteEmail.trim(), role: inviteRole });
|
||||
await api.orgs.invites.create({
|
||||
slug,
|
||||
email: inviteEmail.trim(),
|
||||
role: inviteRole,
|
||||
});
|
||||
toast.success("Invitation sent!");
|
||||
inviteEmail = "";
|
||||
inviteRole = "member";
|
||||
@@ -99,9 +126,13 @@ async function handleCancelInvite(inviteId: number, email: string) {
|
||||
try {
|
||||
await api.orgs.invites.cancel({ slug, inviteId });
|
||||
toast.success("Invitation cancelled");
|
||||
await queryClient.invalidateQueries({ queryKey: ["org", slug, "invites"] });
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["org", slug, "invites"],
|
||||
});
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to cancel invitation");
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : "Failed to cancel invitation",
|
||||
);
|
||||
}
|
||||
};
|
||||
confirmDialogOpen = true;
|
||||
@@ -110,7 +141,10 @@ async function handleCancelInvite(inviteId: number, email: string) {
|
||||
/**
|
||||
* Update member role
|
||||
*/
|
||||
async function handleUpdateRole(userId: number, newRole: "owner" | "admin" | "member") {
|
||||
async function handleUpdateRole(
|
||||
userId: number,
|
||||
newRole: "owner" | "admin" | "member",
|
||||
) {
|
||||
try {
|
||||
await api.orgs.members.updateRole({ slug, userId, role: newRole });
|
||||
toast.success("Role updated");
|
||||
@@ -123,7 +157,11 @@ async function handleUpdateRole(userId: number, newRole: "owner" | "admin" | "me
|
||||
/**
|
||||
* Remove member
|
||||
*/
|
||||
async function handleRemoveMember(userId: number, displayName: string | null, email: string) {
|
||||
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";
|
||||
@@ -131,7 +169,9 @@ async function handleRemoveMember(userId: number, displayName: string | null, em
|
||||
try {
|
||||
await api.orgs.members.remove({ slug, userId });
|
||||
toast.success("Member removed");
|
||||
await queryClient.invalidateQueries({ queryKey: ["org", slug, "members"] });
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["org", slug, "members"],
|
||||
});
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to remove member");
|
||||
}
|
||||
@@ -160,9 +200,15 @@ function formatRelativeTime(date: Date): string {
|
||||
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";
|
||||
if (days < 0) {
|
||||
return "Expired";
|
||||
}
|
||||
if (days === 0) {
|
||||
return "Today";
|
||||
}
|
||||
if (days === 1) {
|
||||
return "Tomorrow";
|
||||
}
|
||||
return `${days} days`;
|
||||
}
|
||||
|
||||
@@ -170,9 +216,15 @@ function formatRelativeTime(date: Date): string {
|
||||
* 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
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -180,8 +232,12 @@ function canRemoveMember(memberRole: string, memberId: number): boolean {
|
||||
* 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;
|
||||
if (isOwner) {
|
||||
return ["member", "admin", "owner"] as const;
|
||||
}
|
||||
if (currentUserRole === "admin") {
|
||||
return ["member", "admin"] as const;
|
||||
}
|
||||
return ["member"] as const;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
LogOut,
|
||||
Settings,
|
||||
Trash2,
|
||||
} from "@lucide/svelte";
|
||||
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
||||
import { goto } from "$app/navigation";
|
||||
import { getContext } from "svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { Settings, Loader2, AlertCircle, AlertTriangle, LogOut, Trash2 } from "@lucide/svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
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 { ConfirmDialog } from "$lib/components/org";
|
||||
import { Alert, AlertDescription } from "$lib/components/ui/alert";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "$lib/components/ui/card";
|
||||
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
|
||||
@@ -59,8 +72,8 @@ $effect(() => {
|
||||
// Track if form is dirty
|
||||
const isDirty = $derived(
|
||||
orgQuery.data &&
|
||||
(displayName !== orgQuery.data.displayName ||
|
||||
logoUrl !== (orgQuery.data.logoUrl || ""))
|
||||
(displayName !== orgQuery.data.displayName ||
|
||||
logoUrl !== (orgQuery.data.logoUrl || "")),
|
||||
);
|
||||
|
||||
// Confirmation dialog state
|
||||
@@ -76,7 +89,9 @@ let isConfirmLoading = $state(false);
|
||||
* Save org settings
|
||||
*/
|
||||
async function handleSave() {
|
||||
if (!canManageOrg) return;
|
||||
if (!canManageOrg) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
try {
|
||||
@@ -100,7 +115,8 @@ async function handleSave() {
|
||||
*/
|
||||
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.";
|
||||
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 () => {
|
||||
@@ -110,7 +126,9 @@ function handleLeave() {
|
||||
await queryClient.invalidateQueries({ queryKey: ["orgs"] });
|
||||
goto("/dashboard");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to leave organization");
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : "Failed to leave organization",
|
||||
);
|
||||
}
|
||||
};
|
||||
confirmDialogOpen = true;
|
||||
@@ -131,7 +149,9 @@ function handleDelete() {
|
||||
await queryClient.invalidateQueries({ queryKey: ["orgs"] });
|
||||
goto("/dashboard");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to delete organization");
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : "Failed to delete organization",
|
||||
);
|
||||
}
|
||||
};
|
||||
confirmDialogOpen = true;
|
||||
|
||||
@@ -61,11 +61,19 @@ async function acceptInvite(): Promise<void> {
|
||||
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 =
|
||||
"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 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;
|
||||
}
|
||||
|
||||
@@ -2363,14 +2363,24 @@ _Depends on: J1-J6, C3_
|
||||
- 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) ✅
|
||||
|
||||
_Depends on: K1-K5, C3_
|
||||
_Can run parallel to L_
|
||||
|
||||
- [ ] **M1**: Create `/admin` dashboard page
|
||||
- [ ] **M2**: Create `/admin/orgs` pages (list, new, details)
|
||||
- [ ] **M3**: Create `/admin/users` pages (list, details)
|
||||
- [x] **M1**: Create `/admin` dashboard page
|
||||
- [x] **M2**: Create `/admin/orgs` pages (list, new, details)
|
||||
- [x] **M3**: Create `/admin/users` pages (list, details)
|
||||
|
||||
**Implementation notes:**
|
||||
- Admin layout at `/routes/admin/+layout.svelte` provides superuser access control
|
||||
- Redirects non-superusers to `/dashboard` with toast error
|
||||
- Admin dashboard shows org/user counts with quick action links
|
||||
- Org management: list all orgs, create new with owner email, view/edit details, manage sites
|
||||
- User management: list all users, view details, toggle superuser status, confirm email
|
||||
- Sidebar shows admin link (shield icon) only for superusers
|
||||
- Reusable component: `$lib/components/admin/superuser-badge.svelte`
|
||||
- All destructive actions use ConfirmDialog
|
||||
|
||||
---
|
||||
|
||||
|
||||
317
docs/test-plans/admin.md
Normal file
317
docs/test-plans/admin.md
Normal file
@@ -0,0 +1,317 @@
|
||||
# Test Plan: Admin Dashboard (Workstream M)
|
||||
|
||||
## Overview
|
||||
|
||||
Manual UI test plan for superuser-only admin management pages:
|
||||
- `/admin` - Admin dashboard
|
||||
- `/admin/orgs` - Organization list
|
||||
- `/admin/orgs/new` - Create organization
|
||||
- `/admin/orgs/[slug]` - Organization details
|
||||
- `/admin/users` - User list
|
||||
- `/admin/users/[email]` - User details
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Dev server running: `bun run --cwd apps/publisher-dashboard dev`
|
||||
- Test accounts:
|
||||
- Superuser account (has `is_superuser = true`)
|
||||
- Regular user account (not a superuser)
|
||||
- At least one organization with sites
|
||||
- At least one user who is not a superuser
|
||||
|
||||
---
|
||||
|
||||
## 1. Access Control
|
||||
|
||||
### 1.1 Superuser Access
|
||||
- [ ] Superuser visiting `/admin` sees admin dashboard
|
||||
- [ ] Superuser can access all admin sub-pages
|
||||
|
||||
### 1.2 Non-Superuser Access
|
||||
- [ ] Regular user visiting `/admin` gets redirected to `/dashboard`
|
||||
- [ ] Toast error message: "Access denied. Superuser privileges required."
|
||||
- [ ] Regular user visiting `/admin/orgs` gets redirected
|
||||
- [ ] Regular user visiting `/admin/users` gets redirected
|
||||
|
||||
### 1.3 Unauthenticated Access
|
||||
- [ ] Unauthenticated user visiting `/admin` redirects to `/auth/login`
|
||||
- [ ] After login as superuser, returns to `/admin`
|
||||
|
||||
---
|
||||
|
||||
## 2. Admin Dashboard (`/admin`)
|
||||
|
||||
### 2.1 Display
|
||||
- [ ] Page title is "Admin Dashboard"
|
||||
- [ ] Red "Admin" badge visible at top
|
||||
- [ ] Summary cards display:
|
||||
- Organizations card with correct count
|
||||
- Users card with correct count
|
||||
- [ ] Cards are clickable and navigate to respective list pages
|
||||
|
||||
### 2.2 Quick Actions
|
||||
- [ ] "New Organization" button visible
|
||||
- [ ] Button navigates to `/admin/orgs/new`
|
||||
|
||||
### 2.3 Loading States
|
||||
- [ ] Loading spinner shows while fetching data
|
||||
- [ ] Error state displays if API fails
|
||||
|
||||
---
|
||||
|
||||
## 3. Organization List (`/admin/orgs`)
|
||||
|
||||
### 3.1 Display
|
||||
- [ ] Page title is "Organizations"
|
||||
- [ ] Header shows "Organizations (count)" with correct count
|
||||
- [ ] "New Organization" button visible in header
|
||||
- [ ] Table displays all organizations (not just user's orgs)
|
||||
|
||||
### 3.2 Table Content
|
||||
- [ ] Slug column displays org slug
|
||||
- [ ] Display Name column shows org name
|
||||
- [ ] Created At column shows formatted date
|
||||
- [ ] Actions column has View and Delete buttons
|
||||
|
||||
### 3.3 View Action
|
||||
- [ ] View button navigates to `/admin/orgs/[slug]`
|
||||
|
||||
### 3.4 Delete Action
|
||||
- [ ] Delete button opens confirmation dialog
|
||||
- [ ] Dialog shows org name and warning message
|
||||
- [ ] Cancel button closes dialog without action
|
||||
- [ ] Confirm button deletes organization
|
||||
- [ ] Success toast: "Organization deleted"
|
||||
- [ ] Org disappears from list after deletion
|
||||
- [ ] Error toast on failure
|
||||
|
||||
### 3.5 Empty State
|
||||
- [ ] Shows appropriate message when no organizations exist
|
||||
|
||||
---
|
||||
|
||||
## 4. Create Organization (`/admin/orgs/new`)
|
||||
|
||||
### 4.1 Display
|
||||
- [ ] Page title is "New Organization"
|
||||
- [ ] Back link "Back to organizations" works
|
||||
|
||||
### 4.2 Form Fields
|
||||
- [ ] Slug input: accepts lowercase alphanumeric and hyphens
|
||||
- [ ] Slug input: auto-converts uppercase to lowercase
|
||||
- [ ] Slug input: strips invalid characters
|
||||
- [ ] Display Name input: accepts any text
|
||||
- [ ] Owner Email input: validates email format
|
||||
|
||||
### 4.3 Form Validation
|
||||
- [ ] Submit button disabled when fields are empty
|
||||
- [ ] Submit button enabled when all fields filled
|
||||
- [ ] Form submits on button click
|
||||
|
||||
### 4.4 Submit Flow
|
||||
- [ ] Loading state on submit button
|
||||
- [ ] Success toast: "Organization created"
|
||||
- [ ] Redirects to `/admin/orgs` on success
|
||||
- [ ] Error toast on failure (e.g., slug already exists)
|
||||
|
||||
---
|
||||
|
||||
## 5. Organization Details (`/admin/orgs/[slug]`)
|
||||
|
||||
### 5.1 Header Section
|
||||
- [ ] Org logo displays if set, placeholder icon otherwise
|
||||
- [ ] Display name shown prominently
|
||||
- [ ] Slug displayed
|
||||
- [ ] Created date shown
|
||||
|
||||
### 5.2 Settings Card
|
||||
- [ ] 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: "Organization updated"
|
||||
- [ ] Changes reflected after save
|
||||
|
||||
### 5.3 Sites Card
|
||||
- [ ] Title shows "Sites (count)"
|
||||
- [ ] Table shows all sites for the org
|
||||
- [ ] Each site has domain and Remove button
|
||||
|
||||
### 5.4 Add Site
|
||||
- [ ] Domain input visible
|
||||
- [ ] Add button visible
|
||||
- [ ] Adding valid domain shows success toast
|
||||
- [ ] New site appears in list
|
||||
- [ ] Error toast on invalid/duplicate domain
|
||||
|
||||
### 5.5 Remove Site
|
||||
- [ ] Remove button opens confirmation dialog
|
||||
- [ ] Dialog shows domain being removed
|
||||
- [ ] Confirm removes site from list
|
||||
- [ ] Success toast on removal
|
||||
|
||||
### 5.6 Danger Zone
|
||||
- [ ] Card has red border styling
|
||||
- [ ] Warning text about permanent deletion
|
||||
- [ ] Delete button opens confirmation dialog
|
||||
- [ ] Confirm deletes org and redirects to `/admin/orgs`
|
||||
- [ ] Success toast on deletion
|
||||
|
||||
### 5.7 Navigation
|
||||
- [ ] Back link works
|
||||
- [ ] 404 error for non-existent org slug
|
||||
|
||||
---
|
||||
|
||||
## 6. User List (`/admin/users`)
|
||||
|
||||
### 6.1 Display
|
||||
- [ ] Page title is "Users"
|
||||
- [ ] Header shows "Users (count)" with correct count
|
||||
- [ ] Table displays all users in system
|
||||
|
||||
### 6.2 Table Content
|
||||
- [ ] Email column displays user email
|
||||
- [ ] Display Name column shows name (or "-" if not set)
|
||||
- [ ] Email Verified column shows checkmark or X icon
|
||||
- [ ] Superuser column shows SuperuserBadge for superusers
|
||||
- [ ] Actions column has View button
|
||||
|
||||
### 6.3 View Action
|
||||
- [ ] View button navigates to `/admin/users/[email]`
|
||||
- [ ] Email is URL-encoded in the link
|
||||
|
||||
### 6.4 Empty State
|
||||
- [ ] Shows appropriate message when no users exist
|
||||
|
||||
---
|
||||
|
||||
## 7. User Details (`/admin/users/[email]`)
|
||||
|
||||
### 7.1 Header Section
|
||||
- [ ] Avatar with initials displays
|
||||
- [ ] Display name shown (or "Unknown" if not set)
|
||||
- [ ] Email shown below name
|
||||
- [ ] SuperuserBadge shown if user is superuser
|
||||
|
||||
### 7.2 Profile Info Card
|
||||
- [ ] Email displayed (read-only)
|
||||
- [ ] Display Name displayed
|
||||
- [ ] Full Name displayed
|
||||
- [ ] Phone Number displayed
|
||||
- [ ] Email Verified status (Yes/No)
|
||||
|
||||
### 7.3 Permissions Card
|
||||
- [ ] Superuser checkbox visible
|
||||
- [ ] Checkbox reflects current status
|
||||
- [ ] Save button disabled when no changes
|
||||
- [ ] Save button enabled when checkbox changed
|
||||
|
||||
### 7.4 Toggle Superuser
|
||||
- [ ] Can grant superuser to regular user
|
||||
- [ ] Can revoke superuser from superuser (if not self)
|
||||
- [ ] Success toast on save
|
||||
- [ ] Cannot demote self (checkbox disabled when viewing own profile)
|
||||
- [ ] Warning shown when viewing own profile
|
||||
|
||||
### 7.5 Actions Card
|
||||
- [ ] "Confirm Email" button visible only if email not verified
|
||||
- [ ] Hidden if email already verified
|
||||
- [ ] Button confirms email on click
|
||||
- [ ] Success toast: "Email confirmed"
|
||||
- [ ] Button disappears after confirmation
|
||||
|
||||
### 7.6 Navigation
|
||||
- [ ] Back link works
|
||||
- [ ] 404 error for non-existent user email
|
||||
|
||||
---
|
||||
|
||||
## 8. Sidebar Navigation
|
||||
|
||||
### 8.1 Admin Link
|
||||
- [ ] Shield icon visible for superusers
|
||||
- [ ] Hidden for regular users
|
||||
- [ ] Tooltip shows "Admin" on hover
|
||||
- [ ] Clicking navigates to `/admin`
|
||||
- [ ] Active state (red tint) when on `/admin` routes
|
||||
|
||||
---
|
||||
|
||||
## 9. Cross-Cutting Concerns
|
||||
|
||||
### 9.1 Loading States
|
||||
- [ ] All pages show loading spinner during data fetch
|
||||
- [ ] Buttons show loading state during operations
|
||||
|
||||
### 9.2 Error Handling
|
||||
- [ ] API errors display user-friendly messages
|
||||
- [ ] Toast notifications for action results
|
||||
- [ ] Error states don't crash the app
|
||||
|
||||
### 9.3 Responsive Design
|
||||
- [ ] Pages render correctly on mobile viewport
|
||||
- [ ] Tables scroll horizontally on small screens
|
||||
- [ ] Forms stack vertically on mobile
|
||||
|
||||
### 9.4 Query Invalidation
|
||||
- [ ] After org create: org list refreshes
|
||||
- [ ] After org delete: org list refreshes
|
||||
- [ ] After org update: org details refresh
|
||||
- [ ] After add site: sites list refreshes
|
||||
- [ ] After remove site: sites list refreshes
|
||||
- [ ] After user update: user details refresh
|
||||
- [ ] After confirm email: user details refresh
|
||||
|
||||
---
|
||||
|
||||
## 10. Edge Cases
|
||||
|
||||
### 10.1 Self-Demotion Prevention
|
||||
- [ ] Cannot remove own superuser status
|
||||
- [ ] Warning message explains why
|
||||
|
||||
### 10.2 Special Characters in Email
|
||||
- [ ] User with `+` in email can be viewed
|
||||
- [ ] User with `.` in email can be viewed
|
||||
- [ ] Email properly URL-encoded/decoded
|
||||
|
||||
### 10.3 Long Content
|
||||
- [ ] Long org names truncate or wrap properly
|
||||
- [ ] Long email addresses don't break layout
|
||||
- [ ] Long URLs in logo field don't break layout
|
||||
|
||||
### 10.4 Empty States
|
||||
- [ ] Org with no sites shows "No sites" message
|
||||
- [ ] Empty org list shows appropriate message
|
||||
- [ ] Empty user list shows appropriate message
|
||||
|
||||
---
|
||||
|
||||
## Test Matrix: Admin vs Non-Admin
|
||||
|
||||
| Feature | Superuser | Regular User |
|
||||
|---------|-----------|--------------|
|
||||
| View admin dashboard | Yes | Redirected |
|
||||
| View org list | Yes | Redirected |
|
||||
| Create organization | Yes | Redirected |
|
||||
| View org details | Yes | Redirected |
|
||||
| Edit org settings | Yes | Redirected |
|
||||
| Manage org sites | Yes | Redirected |
|
||||
| Delete organization | Yes | Redirected |
|
||||
| View user list | Yes | Redirected |
|
||||
| View user details | Yes | Redirected |
|
||||
| Toggle superuser | Yes (not self) | Redirected |
|
||||
| Confirm user email | Yes | Redirected |
|
||||
| See admin link in sidebar | Yes | No |
|
||||
|
||||
---
|
||||
|
||||
## Regression Checklist
|
||||
|
||||
After any changes to admin pages, verify:
|
||||
- [ ] Access control still redirects non-superusers
|
||||
- [ ] All CRUD operations function
|
||||
- [ ] Error states still display
|
||||
- [ ] Navigation works end-to-end
|
||||
- [ ] Sidebar admin link visibility correct
|
||||
Reference in New Issue
Block a user