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">
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;

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 RoleBadge } from "./role-badge.svelte";

View File

@@ -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",

View File

@@ -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";
@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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
@@ -60,7 +73,7 @@ $effect(() => {
const isDirty = $derived(
orgQuery.data &&
(displayName !== orgQuery.data.displayName ||
logoUrl !== (orgQuery.data.logoUrl || ""))
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;

View File

@@ -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;
}