Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions app/components/SetupBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { WarningIcon } from "@phosphor-icons/react";
import { Link as RouterLink, useLocation } from "react-router";
import { useSetupStatus } from "~/queries/setup";

export function SetupBanner() {
const location = useLocation();
const { data } = useSetupStatus();

if (!data || data.isComplete || location.pathname === "/setup") return null;

const requiredIncomplete = data.steps.filter((s) => s.required && s.status !== "complete" && s.status !== "info");
if (requiredIncomplete.length === 0) return null;

return (
<div className="bg-kumo-warning/10 border-b border-kumo-warning/20 px-4 py-2.5">
<div className="mx-auto max-w-7xl flex items-center gap-2">
<WarningIcon size={16} weight="fill" className="text-kumo-warning shrink-0" />
<span className="text-sm text-kumo-default">
Setup incomplete — {requiredIncomplete.length} required step{requiredIncomplete.length > 1 ? "s" : ""} remaining
</span>
<RouterLink to="/setup" className="text-sm font-medium text-kumo-default underline underline-offset-2 hover:text-kumo-subtle ml-1">
Finish setup
</RouterLink>
</div>
</div>
);
}
1 change: 1 addition & 0 deletions app/queries/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@ export const queryKeys = {
["search", mailboxId, query, page] as const,
},
config: ["config"] as const,
setupStatus: ["setupStatus"] as const,
};
11 changes: 11 additions & 0 deletions app/queries/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import api from "~/services/api";
import { queryKeys } from "./keys";

export function useSetupStatus() {
return useQuery({
queryKey: queryKeys.setupStatus,
queryFn: () => api.getSetupStatus(),
staleTime: 0,
});
}
66 changes: 64 additions & 2 deletions app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
} from "@cloudflare/kumo";
import { WarningIcon } from "@phosphor-icons/react";
import { MutationCache, QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { forwardRef, useState } from "react";
import { forwardRef, useEffect, useState } from "react";
import {
isRouteErrorResponse,
Links,
Expand All @@ -21,8 +21,12 @@ import {
Link as RouterLink,
Scripts,
ScrollRestoration,
useLocation,
useNavigate,
} from "react-router";
import { SetupBanner } from "~/components/SetupBanner";
import { ApiError } from "~/services/api";
import { useSetupStatus } from "~/queries/setup";
import "./index.css";

function makeQueryClient() {
Expand Down Expand Up @@ -109,16 +113,74 @@ export function HydrateFallback() {
);
}

function AuthGuard({ children }: { children: React.ReactNode }) {
const location = useLocation();
const navigate = useNavigate();
const { data: setupData, isLoading: setupLoading, isError: setupError } = useSetupStatus();
const isBrowser = typeof window !== "undefined";

useEffect(() => {
if (!isBrowser) return;
if (setupLoading) return;
if (location.pathname === "/setup") return;
if (setupError || !setupData) return;

const accessStep = setupData.steps.find((s) => s.id === "access");
if (accessStep?.status !== "complete") {
navigate("/setup", { replace: true });
}
}, [isBrowser, setupLoading, setupError, setupData, location.pathname, navigate]);

const showLoading = isBrowser && setupLoading && location.pathname !== "/setup";
const showError = isBrowser && setupError && location.pathname !== "/setup";

if (showLoading) {
return (
<div className="flex items-center justify-center h-screen">
<Loader size="lg" />
</div>
);
}

if (showError) {
return (
<div className="flex items-center justify-center min-h-screen p-8">
<Empty
icon={<WarningIcon size={48} className="text-kumo-inactive" />}
title="Unable to verify access"
description="Could not load setup status. Please refresh the page."
contents={
<Button
variant="primary"
onClick={() => {
window.location.reload();
}}
>
Retry
</Button>
}
/>
</div>
);
}

return children;
}

export default function App() {
// Use useState to ensure each SSR request gets a fresh client while the
// browser reuses the same singleton across navigations.
const [queryClient] = useState(getQueryClient);

return (
<QueryClientProvider client={queryClient}>
<LinkProvider component={KumoLink}>
<TooltipProvider>
<Toasty>
<Outlet />
<AuthGuard>
<SetupBanner />
<Outlet />
</AuthGuard>
</Toasty>
</TooltipProvider>
</LinkProvider>
Expand Down
1 change: 1 addition & 0 deletions app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {

export default [
index("routes/home.tsx"),
route("setup", "routes/setup.tsx"),
route("mailbox/:mailboxId", "routes/mailbox.tsx", [
index("routes/mailbox-index.tsx"),
route("emails/:folder", "routes/email-list.tsx"),
Expand Down
201 changes: 117 additions & 84 deletions app/routes/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
Text,
useKumoToastManager,
} from "@cloudflare/kumo";
import { EnvelopeIcon, PlusIcon, TrashIcon } from "@phosphor-icons/react";
import { EnvelopeIcon, GearIcon, PlusIcon, TrashIcon } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { type FormEvent, useEffect, useRef, useState } from "react";
import { Link as RouterLink } from "react-router";
Expand Down Expand Up @@ -145,15 +145,22 @@ export default function HomeRoute() {
<div className="mb-8">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-kumo-default">Mailboxes</h1>
{!isConfigured && (
<Button
variant="primary"
icon={<PlusIcon size={16} />}
onClick={() => setIsCreateOpen(true)}
>
New Mailbox
</Button>
)}
<div className="flex items-center gap-2">
<RouterLink to="/setup">
<Button variant="ghost" size="sm" icon={<GearIcon size={16} />}>
Setup
</Button>
</RouterLink>
{!isConfigured && (
<Button
variant="primary"
icon={<PlusIcon size={16} />}
onClick={() => setIsCreateOpen(true)}
>
New Mailbox
</Button>
)}
</div>
</div>
{domains.length > 0 && (
<p className="text-sm text-kumo-subtle mt-1">
Expand Down Expand Up @@ -222,19 +229,30 @@ export default function HomeRoute() {
No mailboxes yet
</h3>
<p className="text-sm text-kumo-subtle max-w-sm mb-5">
{isConfigured
? "Your email routing is configured but no mailboxes have been created yet. They will appear here automatically."
: "Create a mailbox to start sending and receiving emails with your domain."}
{domains.length === 0
? "No domains are configured. Set up your domain to start sending and receiving emails."
: isConfigured
? "Your email routing is configured but no mailboxes have been created yet. They will appear here automatically."
: "Create a mailbox to start sending and receiving emails with your domain."}
</p>
{!isConfigured && (
<Button
variant="primary"
icon={<PlusIcon size={16} />}
onClick={() => setIsCreateOpen(true)}
>
Create Mailbox
</Button>
)}
<div className="flex items-center gap-2">
{domains.length === 0 && (
<RouterLink to="/setup">
<Button variant="primary" icon={<GearIcon size={16} />}>
Go to Setup
</Button>
</RouterLink>
)}
{!isConfigured && domains.length > 0 && (
<Button
variant="primary"
icon={<PlusIcon size={16} />}
onClick={() => setIsCreateOpen(true)}
>
Create Mailbox
</Button>
)}
</div>
</div>
</div>
)}
Expand All @@ -252,70 +270,85 @@ export default function HomeRoute() {
{createError}
</Text>
)}
<div>
<span className="text-sm font-medium text-kumo-default mb-1.5 block">
Email Address
</span>
<div className="flex items-center gap-2">
<div className="flex-1">
<Input
aria-label="Address prefix"
placeholder="info"
size="sm"
value={newPrefix}
onChange={(e) => setNewPrefix(e.target.value)}
required
/>
</div>
<span className="text-sm text-kumo-subtle">@</span>
{domains.length > 1 ? (
<div className="flex-1">
<Select
aria-label="Domain"
value={selectedDomain}
onValueChange={(value) => {
if (value) setSelectedDomain(value);
}}
>
{domains.map((d) => (
<Select.Option key={d} value={d}>
{d}
</Select.Option>
))}
</Select>
</div>
) : (
<span className="text-sm text-kumo-subtle">
{selectedDomain || "no domain"}
</span>
)}
{domains.length === 0 ? (
<div className="text-center py-4">
<p className="text-sm text-kumo-subtle mb-3">
No domains configured. Set up your domain first.
</p>
<RouterLink to="/setup">
<Button variant="primary" size="sm" icon={<GearIcon size={16} />}>
Go to Setup
</Button>
</RouterLink>
</div>
</div>
<Input
label="Display Name (optional)"
placeholder="Info"
size="sm"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
<div className="flex justify-end gap-2 pt-2">
<Dialog.Close
render={(props) => (
<Button {...props} variant="secondary" size="sm">
Cancel
) : (
<>
<div>
<span className="text-sm font-medium text-kumo-default mb-1.5 block">
Email Address
</span>
<div className="flex items-center gap-2">
<div className="flex-1">
<Input
aria-label="Address prefix"
placeholder="info"
size="sm"
value={newPrefix}
onChange={(e) => setNewPrefix(e.target.value)}
required
/>
</div>
<span className="text-sm text-kumo-subtle">@</span>
{domains.length > 1 ? (
<div className="flex-1">
<Select
aria-label="Domain"
value={selectedDomain}
onValueChange={(value) => {
if (value) setSelectedDomain(value);
}}
>
{domains.map((d) => (
<Select.Option key={d} value={d}>
{d}
</Select.Option>
))}
</Select>
</div>
) : (
<span className="text-sm text-kumo-subtle">
{selectedDomain || "no domain"}
</span>
)}
</div>
</div>
<Input
label="Display Name (optional)"
placeholder="Info"
size="sm"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
<div className="flex justify-end gap-2 pt-2">
<Dialog.Close
render={(props) => (
<Button {...props} variant="secondary" size="sm">
Cancel
</Button>
)}
/>
<Button
type="submit"
variant="primary"
size="sm"
loading={isCreating}
disabled={!selectedDomain}
>
Create
</Button>
)}
/>
<Button
type="submit"
variant="primary"
size="sm"
loading={isCreating}
disabled={!selectedDomain}
>
Create
</Button>
</div>
</div>
</>
)}
</form>
</Dialog>
</Dialog.Root>
Expand Down
Loading