wip: cleanup

This commit is contained in:
RevIQ
2026-01-09 05:18:31 -05:00
parent 2f40ff119d
commit cc77211969
8 changed files with 203 additions and 71 deletions

View File

@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { X } from "@lucide/svelte"; 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 { Button } from "$lib/components/ui/button";
import { cn } from "$lib/utils";
interface Props { interface Props {
open: boolean; open: boolean;

View File

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

View File

@@ -1,10 +1,15 @@
<script lang="ts"> <script lang="ts">
import { AlertCircle, Building2, Loader2 } from "@lucide/svelte";
import { createQuery } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { Building2, Loader2, AlertCircle } from "@lucide/svelte";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte"; 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 * Dashboard page - lists all organizations the user is a member of
@@ -19,7 +24,9 @@ const orgsQuery = createQuery(() => ({
// Redirect to login on auth error // Redirect to login on auth error
$effect(() => { $effect(() => {
if (orgsQuery.error) { 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 diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24)); const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return "Today"; if (days === 0) {
if (days === 1) return "Yesterday"; return "Today";
if (days < 7) return `${days} days ago`; }
if (days < 30) return `${Math.floor(days / 7)} weeks ago`; 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", { return date.toLocaleDateString("en-US", {
month: "short", month: "short",

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import { setContext } from "svelte";
import { createQuery } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import { setContext } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/state"; import { page } from "$app/state";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
@@ -39,46 +39,64 @@ const sitesQuery = createQuery(() => ({
const currentUserRole = $derived.by(() => { const currentUserRole = $derived.by(() => {
const me = userQuery.data; const me = userQuery.data;
const members = membersQuery.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; return members.find((m) => m.userId === me.id)?.role ?? null;
}); });
// Check if user can manage org (admin or owner) // Check if user can manage org (admin or owner)
const canManageOrg = $derived( const canManageOrg = $derived(
currentUserRole === "owner" || currentUserRole === "admin" currentUserRole === "owner" || currentUserRole === "admin",
); );
// Check if user is owner // Check if user is owner
const isOwner = $derived(currentUserRole === "owner"); const isOwner = $derived(currentUserRole === "owner");
// Loading state // Loading state
const isLoading = $derived( const isLoading = $derived(userQuery.isPending || membersQuery.isPending);
userQuery.isPending || membersQuery.isPending
);
// Error state // Error state
const error = $derived( const error = $derived(!userQuery.error ? membersQuery.error : null);
!userQuery.error ? membersQuery.error : null
);
// Redirect to login on auth error // Redirect to login on auth error
$effect(() => { $effect(() => {
if (userQuery.error) { if (userQuery.error) {
goto("/auth/login?redirect=" + encodeURIComponent(window.location.pathname)); goto(
`/auth/login?redirect=${encodeURIComponent(window.location.pathname)}`,
);
} }
}); });
// Provide context to child components // Provide context to child components
setContext("orgContext", { setContext("orgContext", {
get slug() { return slug; }, get slug() {
get userQuery() { return userQuery; }, return slug;
get membersQuery() { return membersQuery; }, },
get sitesQuery() { return sitesQuery; }, get userQuery() {
get currentUserRole() { return currentUserRole; }, return userQuery;
get canManageOrg() { return canManageOrg; }, },
get isOwner() { return isOwner; }, get membersQuery() {
get isLoading() { return isLoading; }, return membersQuery;
get error() { return error; }, },
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> </script>

View File

@@ -1,19 +1,34 @@
<script lang="ts"> <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 { 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 { api } from "$lib/api/client";
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte"; 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 { 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 * Org overview page - shows org details, stats, and navigation
*/ */
// Types from API contract // 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]; type OrgSiteOutput = Awaited<ReturnType<typeof api.orgs.sites.list>>[number];
// Get org context from layout // Get org context from layout

View File

@@ -1,24 +1,47 @@
<script lang="ts"> <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 { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { getContext } from "svelte";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { Users, UserPlus, Loader2, AlertCircle, X, Clock } from "@lucide/svelte";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte"; 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 { Button } from "$lib/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "$lib/components/ui/card";
import { Input } from "$lib/components/ui/input"; import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label"; import { Label } from "$lib/components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "$lib/components/ui/table"; import {
import { RoleBadge, ConfirmDialog } from "$lib/components/org"; Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "$lib/components/ui/table";
/** /**
* Members management page * Members management page
*/ */
// Types from API contract // Types from API contract
type OrgMemberOutput = Awaited<ReturnType<typeof api.orgs.members.list>>[number]; type OrgMemberOutput = Awaited<
type OrgInviteOutput = Awaited<ReturnType<typeof api.orgs.invites.list>>[number]; 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>>; type UserProfile = Awaited<ReturnType<typeof api.me.get>>;
// Get org context from layout // Get org context from layout
@@ -76,7 +99,11 @@ async function handleInvite() {
isInviting = true; isInviting = true;
try { 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!"); toast.success("Invitation sent!");
inviteEmail = ""; inviteEmail = "";
inviteRole = "member"; inviteRole = "member";
@@ -99,9 +126,13 @@ async function handleCancelInvite(inviteId: number, email: string) {
try { try {
await api.orgs.invites.cancel({ slug, inviteId }); await api.orgs.invites.cancel({ slug, inviteId });
toast.success("Invitation cancelled"); toast.success("Invitation cancelled");
await queryClient.invalidateQueries({ queryKey: ["org", slug, "invites"] }); await queryClient.invalidateQueries({
queryKey: ["org", slug, "invites"],
});
} catch (e) { } 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; confirmDialogOpen = true;
@@ -110,7 +141,10 @@ async function handleCancelInvite(inviteId: number, email: string) {
/** /**
* Update member role * Update member role
*/ */
async function handleUpdateRole(userId: number, newRole: "owner" | "admin" | "member") { async function handleUpdateRole(
userId: number,
newRole: "owner" | "admin" | "member",
) {
try { try {
await api.orgs.members.updateRole({ slug, userId, role: newRole }); await api.orgs.members.updateRole({ slug, userId, role: newRole });
toast.success("Role updated"); toast.success("Role updated");
@@ -123,7 +157,11 @@ async function handleUpdateRole(userId: number, newRole: "owner" | "admin" | "me
/** /**
* Remove member * 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"; confirmDialogTitle = "Remove Member";
confirmDialogDescription = `Are you sure you want to remove ${displayName || email} from this organization?`; confirmDialogDescription = `Are you sure you want to remove ${displayName || email} from this organization?`;
confirmDialogVariant = "destructive"; confirmDialogVariant = "destructive";
@@ -131,7 +169,9 @@ async function handleRemoveMember(userId: number, displayName: string | null, em
try { try {
await api.orgs.members.remove({ slug, userId }); await api.orgs.members.remove({ slug, userId });
toast.success("Member removed"); toast.success("Member removed");
await queryClient.invalidateQueries({ queryKey: ["org", slug, "members"] }); await queryClient.invalidateQueries({
queryKey: ["org", slug, "members"],
});
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to remove member"); 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 diff = date.getTime() - now.getTime();
const days = Math.ceil(diff / (1000 * 60 * 60 * 24)); const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
if (days < 0) return "Expired"; if (days < 0) {
if (days === 0) return "Today"; return "Expired";
if (days === 1) return "Tomorrow"; }
if (days === 0) {
return "Today";
}
if (days === 1) {
return "Tomorrow";
}
return `${days} days`; return `${days} days`;
} }
@@ -170,9 +216,15 @@ function formatRelativeTime(date: Date): string {
* Check if user can remove a member * Check if user can remove a member
*/ */
function canRemoveMember(memberRole: string, memberId: number): boolean { function canRemoveMember(memberRole: string, memberId: number): boolean {
if (memberId === currentUserId) return false; // Can't remove self if (memberId === currentUserId) {
if (isOwner) return true; // Owners can remove anyone return false; // Can't remove self
if (currentUserRole === "admin" && memberRole === "member") return true; // Admins can remove members }
if (isOwner) {
return true; // Owners can remove anyone
}
if (currentUserRole === "admin" && memberRole === "member") {
return true; // Admins can remove members
}
return false; return false;
} }
@@ -180,8 +232,12 @@ function canRemoveMember(memberRole: string, memberId: number): boolean {
* Get available roles for invite based on current user's role * Get available roles for invite based on current user's role
*/ */
const availableInviteRoles = $derived.by(() => { const availableInviteRoles = $derived.by(() => {
if (isOwner) return ["member", "admin", "owner"] as const; if (isOwner) {
if (currentUserRole === "admin") return ["member", "admin"] as const; return ["member", "admin", "owner"] as const;
}
if (currentUserRole === "admin") {
return ["member", "admin"] as const;
}
return ["member"] as const; return ["member"] as const;
}); });
</script> </script>

View File

@@ -1,17 +1,30 @@
<script lang="ts"> <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 { createQuery, useQueryClient } from "@tanstack/svelte-query";
import { goto } from "$app/navigation"; import { getContext } from "svelte";
import { toast } from "svelte-sonner"; 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 { api } from "$lib/api/client";
import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte"; 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 { 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 { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label"; import { Label } from "$lib/components/ui/label";
import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { ConfirmDialog } from "$lib/components/org";
/** /**
* Org settings page * Org settings page
@@ -60,7 +73,7 @@ $effect(() => {
const isDirty = $derived( const isDirty = $derived(
orgQuery.data && orgQuery.data &&
(displayName !== orgQuery.data.displayName || (displayName !== orgQuery.data.displayName ||
logoUrl !== (orgQuery.data.logoUrl || "")) logoUrl !== (orgQuery.data.logoUrl || "")),
); );
// Confirmation dialog state // Confirmation dialog state
@@ -76,7 +89,9 @@ let isConfirmLoading = $state(false);
* Save org settings * Save org settings
*/ */
async function handleSave() { async function handleSave() {
if (!canManageOrg) return; if (!canManageOrg) {
return;
}
isSaving = true; isSaving = true;
try { try {
@@ -100,7 +115,8 @@ async function handleSave() {
*/ */
function handleLeave() { function handleLeave() {
confirmDialogTitle = "Leave Organization"; 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"; confirmDialogVariant = "destructive";
confirmDialogConfirmLabel = "Leave Organization"; confirmDialogConfirmLabel = "Leave Organization";
confirmAction = async () => { confirmAction = async () => {
@@ -110,7 +126,9 @@ function handleLeave() {
await queryClient.invalidateQueries({ queryKey: ["orgs"] }); await queryClient.invalidateQueries({ queryKey: ["orgs"] });
goto("/dashboard"); goto("/dashboard");
} catch (e) { } 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; confirmDialogOpen = true;
@@ -131,7 +149,9 @@ function handleDelete() {
await queryClient.invalidateQueries({ queryKey: ["orgs"] }); await queryClient.invalidateQueries({ queryKey: ["orgs"] });
goto("/dashboard"); goto("/dashboard");
} catch (e) { } 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; confirmDialogOpen = true;

View File

@@ -61,11 +61,19 @@ async function acceptInvite(): Promise<void> {
if (e instanceof Error) { if (e instanceof Error) {
// Handle specific error cases // Handle specific error cases
if (e.message.includes("expired") || e.message.includes("invalid")) { if (e.message.includes("expired") || e.message.includes("invalid")) {
error = "This invitation has expired or is invalid. Please ask for a new invitation."; error =
} else if (e.message.includes("already") || e.message.includes("member")) { "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."; error = "You're already a member of this organization.";
} else if (e.message.includes("email") || e.message.includes("mismatch")) { } else if (
error = "This invitation was sent to a different email address. Please log in with the correct account."; 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 { } else {
error = e.message; error = e.message;
} }