Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
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,
});
}
62 changes: 54 additions & 8 deletions app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ import {
Link as RouterLink,
Scripts,
ScrollRestoration,
useLocation,
} from "react-router";
import { ApiError } from "~/services/api";
import { useSetupStatus } from "~/queries/setup";
import "./index.css";

function makeQueryClient() {
Expand Down Expand Up @@ -109,6 +111,30 @@ export function HydrateFallback() {
);
}

function SetupBanner() {
Comment thread
harshil1712 marked this conversation as resolved.
Outdated
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>
);
}

export default function App() {
// Use useState to ensure each SSR request gets a fresh client while the
// browser reuses the same singleton across navigations.
Expand All @@ -118,6 +144,7 @@ export default function App() {
<LinkProvider component={KumoLink}>
<TooltipProvider>
<Toasty>
<SetupBanner />
<Outlet />
</Toasty>
</TooltipProvider>
Expand All @@ -130,6 +157,7 @@ export function ErrorBoundary({ error }: { error: unknown }) {
let title = "Something went wrong";
let description = "An unexpected error occurred. Please try again.";
let status: number | null = null;
let isSetupError = false;

if (isRouteErrorResponse(error)) {
status = error.status;
Expand All @@ -141,6 +169,15 @@ export function ErrorBoundary({ error }: { error: unknown }) {
title = `Error ${error.status}`;
description = error.statusText || description;
}
} else if (error instanceof ApiError) {
status = error.status;
title = `Error ${error.status}`;
description = error.message;
if (error.body?.code === "ACCESS_NOT_CONFIGURED" || error.body?.code === "ACCESS_TOKEN_MISSING" || error.body?.code === "ACCESS_TOKEN_INVALID") {
isSetupError = true;
title = "Configuration required";
description = error.body.error as string || "Cloudflare Access is not configured.";
}
} else if (error instanceof Error && import.meta.env.DEV) {
description = error.message;
}
Comment thread
harshil1712 marked this conversation as resolved.
Expand All @@ -152,14 +189,23 @@ export function ErrorBoundary({ error }: { error: unknown }) {
title={status === 404 ? "404 — Page not found" : title}
description={description}
contents={
<Button
variant="primary"
onClick={() => {
window.location.href = "/";
}}
>
Go Home
</Button>
<div className="flex items-center gap-2">
{isSetupError && (
<RouterLink to="/setup">
<Button variant="primary">
Go to Setup
</Button>
</RouterLink>
)}
<Button
variant={isSetupError ? "secondary" : "primary"}
onClick={() => {
window.location.href = "/";
}}
>
Go Home
</Button>
</div>
}
/>
</div>
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