diff --git a/app/(auth)/accept-invite/page.tsx b/app/(auth)/accept-invite/page.tsx index 2a0c407..2da07c8 100644 --- a/app/(auth)/accept-invite/page.tsx +++ b/app/(auth)/accept-invite/page.tsx @@ -1,22 +1,48 @@ "use client"; import { useState } from "react"; -import { useSearchParams } from "next/navigation"; +import { useSearchParams, useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { authClient } from "@/lib/auth/client"; +import { authClient, useSession } from "@/lib/auth/client"; import { toast } from "sonner"; export default function AcceptInvitePage() { + const router = useRouter(); const searchParams = useSearchParams(); - const email = searchParams.get("email") || ""; + const emailFromUrl = searchParams.get("email") || ""; + const invitationId = searchParams.get("invitationId") || ""; + const [email, setEmail] = useState(emailFromUrl); const [isLoading, setIsLoading] = useState(false); + const [isAccepting, setIsAccepting] = useState(false); const [sent, setSent] = useState(false); + const { data: session, isPending } = useSession(); + + async function handleAcceptInvitation() { + setIsAccepting(true); + try { + const result = await authClient.organization.acceptInvitation({ + invitationId, + }); + + if (result.error) { + toast.error(result.error.message || "Failed to accept invitation"); + } else { + toast.success("Invitation accepted!"); + router.push("/start"); + } + } catch (error) { + console.error("Accept invitation error:", error); + toast.error("Failed to accept invitation"); + } finally { + setIsAccepting(false); + } + } async function handleSignIn() { if (!email) { - toast.error("No email provided"); + toast.error("Please enter your email"); return; } @@ -24,7 +50,7 @@ export default function AcceptInvitePage() { try { const result = await authClient.signIn.magicLink({ email, - callbackURL: "/start", + callbackURL: `/accept-invite?email=${encodeURIComponent(email)}${invitationId ? `&invitationId=${invitationId}` : ""}`, }); if (result.error) { @@ -41,6 +67,39 @@ export default function AcceptInvitePage() { } } + if (isPending) { + return ( +
+
Loading...
+
+ ); + } + + if (session?.user) { + return ( +
+
+
+

Accept Invitation

+

+ You're signed in as {session.user.email} +

+
+ + +
+
+ ); + } + if (sent) { return (
@@ -76,8 +135,8 @@ export default function AcceptInvitePage() { id="email" type="email" value={email} - disabled - className="bg-muted" + onChange={(e) => setEmail(e.target.value)} + placeholder="Enter your email" />
diff --git a/app/(dashboard)/[tenant]/projects/[projectId]/events/page.tsx b/app/(dashboard)/[tenant]/projects/[projectId]/events/page.tsx index 864cf9e..67d344f 100644 --- a/app/(dashboard)/[tenant]/projects/[projectId]/events/page.tsx +++ b/app/(dashboard)/[tenant]/projects/[projectId]/events/page.tsx @@ -33,7 +33,8 @@ export default function Events() { const [selectedDate, setSelectedDate] = useState(null); const calendarSubscriptionUrl = useMemo( - () => `/api/calendar/${tenant}/${projectId}/calendar.ics?userId=${session?.user?.id}`, + () => + `/api/calendar/${tenant}/${projectId}/calendar.ics?userId=${session?.user?.id}`, [tenant, projectId, session?.user?.id], ); diff --git a/app/(dashboard)/[tenant]/today/page.tsx b/app/(dashboard)/[tenant]/today/page.tsx index f08b810..6b9859b 100644 --- a/app/(dashboard)/[tenant]/today/page.tsx +++ b/app/(dashboard)/[tenant]/today/page.tsx @@ -51,7 +51,7 @@ export default function Today() { }); const projects = projectsData?.projects; - const isOrgAdmin = projectsData?.isOrgAdmin ?? (tenant === "me"); + const isOrgAdmin = projectsData?.isOrgAdmin ?? tenant === "me"; const { dueToday = [], overDue = [], events = [] } = todayData ?? {}; diff --git a/app/page.tsx b/app/page.tsx index 7cf2c22..b498ea5 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -5,6 +5,7 @@ import { ClientRedirect } from "@/components/core/client-redirect"; import { Footer } from "@/components/layout/footer"; import { Header } from "@/components/layout/header"; import { auth } from "@/lib/auth"; +import { isSignupDisabled } from "@/lib/config"; export default async function Home() { const session = await auth.api.getSession({ @@ -16,7 +17,7 @@ export default async function Home() { return (
-
+
diff --git a/biome.json b/biome.json index 200f5b0..198ab6a 100644 --- a/biome.json +++ b/biome.json @@ -1,6 +1,12 @@ { "$schema": "https://biomejs.dev/schemas/2.3.5/schema.json", "assist": { "actions": { "source": { "organizeImports": "on" } } }, + "css": { + "parser": { + "cssModules": true, + "tailwindDirectives": true + } + }, "linter": { "enabled": true, "rules": { diff --git a/components/core/billing.tsx b/components/core/billing.tsx index bf63a0d..11a956a 100644 --- a/components/core/billing.tsx +++ b/components/core/billing.tsx @@ -1,6 +1,12 @@ "use client"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; export function Billing() { return ( @@ -13,8 +19,8 @@ export function Billing() {

- No billing is required for self-hosted instances. - All features are available. + No billing is required for self-hosted instances. All features are + available.

diff --git a/components/core/notifications.tsx b/components/core/notifications.tsx index 0ef3764..9a5f213 100644 --- a/components/core/notifications.tsx +++ b/components/core/notifications.tsx @@ -14,119 +14,119 @@ import { NotificationItem } from "./notification-item"; import { Panel } from "./panel"; export function Notifications({ - notificationsWire, + notificationsWire, }: { - notificationsWire: string; + notificationsWire: string; }) { - const [open, setOpen] = useState(false); - const trpc = useTRPC(); - const { - data: notifications, - isLoading: notificationsLoading, - refetch: refetchNotifications, - } = useQuery(trpc.user.getUserNotifications.queryOptions()); - const { data: timezone, isLoading: timezoneLoading } = useQuery( - trpc.settings.getTimezone.queryOptions(), - ); + const [open, setOpen] = useState(false); + const trpc = useTRPC(); + const { + data: notifications, + isLoading: notificationsLoading, + refetch: refetchNotifications, + } = useQuery(trpc.user.getUserNotifications.queryOptions()); + const { data: timezone, isLoading: timezoneLoading } = useQuery( + trpc.settings.getTimezone.queryOptions(), + ); - const markNotificationsAsRead = useMutation( - trpc.user.markNotificationsAsRead.mutationOptions({ - onError: displayMutationError, - onSuccess: () => { - refetchNotifications(); - }, - }), - ); + const markNotificationsAsRead = useMutation( + trpc.user.markNotificationsAsRead.mutationOptions({ + onError: displayMutationError, + onSuccess: () => { + refetchNotifications(); + }, + }), + ); - const unreadCount = notifications?.filter((x) => !x.read).length; + const unreadCount = notifications?.filter((x) => !x.read).length; - const onNotification = useCallback( - ({ content }: NotificationPayload) => { - try { - if (!content) return; - refetchNotifications(); - toast.info(content); - } catch (error) { - console.error(error); - } - }, - [refetchNotifications], - ); + const onNotification = useCallback( + ({ content }: NotificationPayload) => { + try { + if (!content) return; + refetchNotifications(); + toast.info(content); + } catch (error) { + console.error(error); + } + }, + [refetchNotifications], + ); - const wireOptions = useMemo( - () => ({ schema: realtimeSchema, notification: onNotification }), - [onNotification], - ); + const wireOptions = useMemo( + () => ({ schema: realtimeSchema, notification: onNotification }), + [onNotification], + ); - useWireEvent(notificationsWire, wireOptions); + useWireEvent(notificationsWire, wireOptions); - return ( - <> - + return ( + <> + - - {notificationsLoading || timezoneLoading ? ( - - ) : ( -
-
-

Notifications

-
- {unreadCount ? ( - - ) : null} - -
-
-
- {notifications?.length && timezone ? ( -
- {notifications.map((notification) => ( - - ))} -
- ) : ( -
- You have no new notifications. -
- )} -
-
- )} -
- - ); + + {notificationsLoading || timezoneLoading ? ( + + ) : ( +
+
+

Notifications

+
+ {unreadCount ? ( + + ) : null} + +
+
+
+ {notifications?.length && timezone ? ( +
+ {notifications.map((notification) => ( + + ))} +
+ ) : ( +
+ You have no new notifications. +
+ )} +
+
+ )} +
+ + ); } diff --git a/components/core/org-switcher.tsx b/components/core/org-switcher.tsx index d80e1f7..2ddacb1 100644 --- a/components/core/org-switcher.tsx +++ b/components/core/org-switcher.tsx @@ -20,7 +20,11 @@ import { DialogTitle, DialogFooter, } from "@/components/ui/dialog"; -import { authClient, useActiveOrganization, useSession } from "@/lib/auth/client"; +import { + authClient, + useActiveOrganization, + useSession, +} from "@/lib/auth/client"; import { toast } from "sonner"; type Organization = { @@ -79,7 +83,9 @@ export function OrgSwitcher() { slug: newOrgName.trim().toLowerCase().replace(/\s+/g, "-"), }); if (result.data) { - await authClient.organization.setActive({ organizationId: result.data.id }); + await authClient.organization.setActive({ + organizationId: result.data.id, + }); setCreateDialogOpen(false); setNewOrgName(""); toast.success("Workspace created"); @@ -130,7 +136,9 @@ export function OrgSwitcher() { > {org.name} - {activeOrg?.id === org.id && } + {activeOrg?.id === org.id && ( + + )} ))} @@ -161,10 +169,16 @@ export function OrgSwitcher() {
- - diff --git a/components/core/permissions-management.tsx b/components/core/permissions-management.tsx index babca80..3f3da4e 100644 --- a/components/core/permissions-management.tsx +++ b/components/core/permissions-management.tsx @@ -5,7 +5,7 @@ import { Plus, Shield, ShieldCheck, Users, X } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; import { Panel } from "@/components/core/panel"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { UserAvatar } from "@/components/core/user-avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -256,12 +256,7 @@ export default function PermissionsManagement({ {availableUsers.map((user: User) => (
- - - - {user.firstName?.[0] || user.email[0]} - - + {user.firstName} {user.lastName} ({user.email}) @@ -354,13 +349,10 @@ export default function PermissionsManagement({ className="flex items-center justify-between" >
- - - - {permission.user.firstName?.[0] || - permission.user.email[0]} - - +

{permission.user.firstName}{" "} diff --git a/components/core/search-panel.tsx b/components/core/search-panel.tsx index 7529942..5374301 100644 --- a/components/core/search-panel.tsx +++ b/components/core/search-panel.tsx @@ -132,9 +132,7 @@ export function SearchSheet({ open, onOpenChange }: SearchSheetProps) { enabled: debouncedQuery.length > 0 && open, }); - const { data: projectsData } = useQuery( - trpc.user.getProjects.queryOptions(), - ); + const { data: projectsData } = useQuery(trpc.user.getProjects.queryOptions()); const projects = projectsData?.projects ?? []; @@ -417,76 +415,78 @@ export function SearchSheet({ open, onOpenChange }: SearchSheetProps) { {searchResults.length !== 1 ? "s" : ""} for "{debouncedQuery}"

- {["project", "tasklist", "task", "event", "post"].map((type) => { - const results = - groupedResults[type as keyof typeof groupedResults]; - if (!results || results.length === 0) return null; + {["project", "tasklist", "task", "event", "post"].map( + (type) => { + const results = + groupedResults[type as keyof typeof groupedResults]; + if (!results || results.length === 0) return null; - return ( -
-
- {getTypeIcon(type)} -

- {getTypeLabel(type)}s ({results.length}) -

-
-
- {results.map((result) => ( - - ))} + + ))} +
-
- ); - })} + ); + }, + )}
)} diff --git a/components/core/user-avatar.tsx b/components/core/user-avatar.tsx index 90218db..3fc3a2d 100644 --- a/components/core/user-avatar.tsx +++ b/components/core/user-avatar.tsx @@ -21,7 +21,8 @@ export const UserAvatar = ({ className, compact = false, }: UserAvatarProps) => { - const seed = user?.id || user?.email || user?.firstName || user?.name || "default"; + const seed = + user?.id || user?.email || user?.firstName || user?.name || "default"; const fallbackText = user?.firstName?.[0] || user?.name?.[0] || "U"; return ( diff --git a/components/core/user-menu.tsx b/components/core/user-menu.tsx index 4bc2ffc..b99654e 100644 --- a/components/core/user-menu.tsx +++ b/components/core/user-menu.tsx @@ -1,8 +1,10 @@ "use client"; +import { LogOut, User } from "lucide-react"; import { useRouter } from "next/navigation"; -import { LogOut } from "lucide-react"; +import { useState } from "react"; import { UserAvatar } from "@/components/core/user-avatar"; +import { UserProfilePanel } from "@/components/core/user-profile-panel"; import { DropdownMenu, DropdownMenuContent, @@ -16,6 +18,7 @@ import { authClient, useSession } from "@/lib/auth/client"; export function UserMenu() { const router = useRouter(); const { data: session } = useSession(); + const [profileOpen, setProfileOpen] = useState(false); if (!session?.user) { return null; @@ -29,30 +32,40 @@ export function UserMenu() { } return ( - - - - - - -
-

{user.name}

-

- {user.email} -

-
-
- - - - Sign out - -
-
+ <> + + + + + + +
+

{user.name}

+

+ {user.email} +

+
+
+ + setProfileOpen(true)} + className="cursor-pointer" + > + + Profile + + + + Sign out + +
+
+ + ); } diff --git a/components/core/user-profile-panel.tsx b/components/core/user-profile-panel.tsx new file mode 100644 index 0000000..e521f91 --- /dev/null +++ b/components/core/user-profile-panel.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { displayMutationError } from "@/lib/utils/error"; +import { useTRPC } from "@/trpc/client"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { Spinner, SpinnerWithSpacing } from "./loaders"; + +interface UserProfilePanelProps { + open: boolean; + setOpen: (open: boolean) => void; +} + +export function UserProfilePanel({ open, setOpen }: UserProfilePanelProps) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const { data: user, isLoading } = useQuery( + trpc.user.getCurrentUser.queryOptions(), + ); + + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + + useEffect(() => { + if (user) { + setFirstName(user.firstName ?? ""); + setLastName(user.lastName ?? ""); + } + }, [user]); + + const updateProfile = useMutation( + trpc.user.updateProfile.mutationOptions({ + onError: displayMutationError, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.user.getCurrentUser.queryKey(), + }); + setOpen(false); + }, + }), + ); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + updateProfile.mutate({ firstName, lastName }); + }; + + return ( + + + + Profile + Update your profile information + + {isLoading ? ( + + ) : ( +
+
+ + setFirstName(e.target.value)} + required + /> +
+
+ + setLastName(e.target.value)} + required + /> +
+
+ + +
+ + + +
+ )} +
+
+ ); +} diff --git a/components/layout/header.tsx b/components/layout/header.tsx index bfe0225..2b1bdaa 100644 --- a/components/layout/header.tsx +++ b/components/layout/header.tsx @@ -6,9 +6,15 @@ import logo from "../../public/images/logo.png"; import { buttonVariants } from "../ui/button"; import { useSession } from "@/lib/auth/client"; -export function Header() { +interface HeaderProps { + disableSignups?: boolean; +} + +export function Header({ disableSignups }: HeaderProps) { const { data: session } = useSession(); + const showLoginButton = session || !disableSignups; + return (
); diff --git a/components/settings/team-settings.tsx b/components/settings/team-settings.tsx index fc76bf4..4a6e7eb 100644 --- a/components/settings/team-settings.tsx +++ b/components/settings/team-settings.tsx @@ -1,5 +1,6 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; import { useState, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -20,6 +21,7 @@ import { } from "@/components/ui/dialog"; import { UserAvatar } from "@/components/core/user-avatar"; import { authClient, useActiveOrganization } from "@/lib/auth/client"; +import { useTRPC } from "@/trpc/client"; import { toast } from "sonner"; import { Mail, UserPlus, X } from "lucide-react"; @@ -32,6 +34,8 @@ type Member = { name: string; email: string; image?: string | null; + firstName?: string | null; + lastName?: string | null; }; }; @@ -45,15 +49,20 @@ type Invitation = { export function TeamSettings() { const { data: activeOrg } = useActiveOrganization(); - const [members, setMembers] = useState([]); + const trpc = useTRPC(); const [invitations, setInvitations] = useState([]); const [inviteDialogOpen, setInviteDialogOpen] = useState(false); const [inviteEmail, setInviteEmail] = useState(""); const [inviteRole, setInviteRole] = useState<"member" | "admin">("member"); const [isInviting, setIsInviting] = useState(false); - const [isLoading, setIsLoading] = useState(true); - const fetchMembers = useCallback(async () => { + const { + data: members = [], + isLoading, + refetch: refetchMembers, + } = useQuery(trpc.permissions.getOrganizationMembers.queryOptions()); + + const fetchInvitations = useCallback(async () => { if (!activeOrg?.id) return; try { @@ -62,19 +71,16 @@ export function TeamSettings() { }); if (result.data) { - setMembers(result.data.members || []); setInvitations(result.data.invitations || []); } } catch (error) { - console.error("Failed to fetch members:", error); - } finally { - setIsLoading(false); + console.error("Failed to fetch invitations:", error); } }, [activeOrg?.id]); useEffect(() => { - fetchMembers(); - }, [fetchMembers]); + fetchInvitations(); + }, [fetchInvitations]); async function inviteMember() { if (!inviteEmail.trim() || !activeOrg?.id) return; @@ -94,7 +100,7 @@ export function TeamSettings() { setInviteDialogOpen(false); setInviteEmail(""); setInviteRole("member"); - fetchMembers(); + fetchInvitations(); } } catch (error) { console.error("Invite error:", error); @@ -112,7 +118,7 @@ export function TeamSettings() { invitationId, }); toast.success("Invitation cancelled"); - fetchMembers(); + fetchInvitations(); } catch (error) { console.error("Cancel invitation error:", error); toast.error("Failed to cancel invitation"); @@ -128,7 +134,7 @@ export function TeamSettings() { organizationId: activeOrg.id, }); toast.success("Member removed"); - fetchMembers(); + refetchMembers(); } catch (error) { console.error("Remove member error:", error); toast.error("Failed to remove member"); @@ -145,7 +151,7 @@ export function TeamSettings() { organizationId: activeOrg.id, }); toast.success("Role updated"); - fetchMembers(); + refetchMembers(); } catch (error) { console.error("Update role error:", error); toast.error("Failed to update role"); @@ -189,13 +195,20 @@ export function TeamSettings() {
-

{member.user.name || member.user.email}

-

{member.user.email}

+

+ {member.user.firstName || member.user.lastName + ? `${member.user.firstName ?? ""} ${member.user.lastName ?? ""}`.trim() + : member.user.name || member.user.email} +

+

+ {member.user.email} +

@@ -207,7 +220,9 @@ export function TeamSettings() { <>