wip: cleanup
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user