Skip to content
Merged
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
60 changes: 45 additions & 15 deletions src/app/dashboard/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,61 @@
import { headers } from "next/headers";
import { AppSidebar } from "@/components/app-sidebar";
import { HeaderBreadcrumb } from "@/components/header-breadcrumb";
import type { OrgOption } from "@/components/org-switcher";
import {
RefreshPendingOverlay,
RefreshPendingProvider,
} from "@/components/refresh-pending";
import { Separator } from "@/components/ui/separator";
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { auth } from "@/lib/auth";
import { getSession } from "@/lib/session";

export default function DashboardLayout({
export default async function DashboardLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
// 側邊欄的組織切換器需要的資料在這裡一次撈好。session 本來就帶著 active org,
// 組織清單也只是一次查詢 —— 交給 client hook 現抓的話,第一次繪製只會是一個
// 空白又不能按的下拉選單。這裡不擋權限(各頁自己 requireOrg()),撈不到就給空的。
const reqHeaders = await headers();
const session = await getSession();
const organizations = session
? ((await auth.api.listOrganizations({ headers: reqHeaders })) ?? [])
: [];
const initialOrganizations: OrgOption[] = organizations.map((o) => ({
id: o.id,
name: o.name,
slug: o.slug,
}));
const initialActiveOrgId = session?.session.activeOrganizationId ?? null;

return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<header className="sticky top-0 z-20 flex h-16 shrink-0 items-center gap-2 border-b bg-background px-4">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="mr-2 data-[orientation=vertical]:h-4"
/>
<HeaderBreadcrumb />
</header>
<main className="flex min-w-0 flex-1 flex-col gap-6 p-6">{children}</main>
</SidebarInset>
</SidebarProvider>
<RefreshPendingProvider>
<SidebarProvider>
<AppSidebar
initialOrganizations={initialOrganizations}
initialActiveOrgId={initialActiveOrgId}
/>
<SidebarInset>
<header className="sticky top-0 z-20 flex h-16 shrink-0 items-center gap-2 border-b bg-background px-4">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="mr-2 data-[orientation=vertical]:h-4"
/>
<HeaderBreadcrumb />
</header>
<RefreshPendingOverlay className="flex min-w-0 flex-1 flex-col gap-6 p-6">
{children}
</RefreshPendingOverlay>
</SidebarInset>
</SidebarProvider>
</RefreshPendingProvider>
);
}
12 changes: 11 additions & 1 deletion src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -216,4 +216,14 @@
html {
@apply font-sans;
}
}
}

/* 組織切換等待 server 重新渲染時,主內容區頂端那條不定量進度條。 */
@keyframes refresh-bar {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(400%);
}
}
98 changes: 15 additions & 83 deletions src/app/onboarding/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Building2, MailCheck } from "lucide-react";
import { authClient } from "@/lib/auth-client";
import {
PendingInvitations,
useUserInvitations,
} from "@/components/pending-invitations";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
Expand All @@ -25,27 +29,16 @@ function slugify(name: string) {
.replaceAll(/^-|-$/g, "");
}

type Invite = {
id: string;
organizationId: string;
organizationName: string;
role: string;
expiresAt: string | Date;
};

export default function OnboardingPage() {
const t = useTranslations("auth.onboarding");
const ti = useTranslations("auth.invites");
const router = useRouter();
const [creating, setCreating] = useState(false);
const [invites, setInvites] = useState<Invite[]>([]);
const [loadingInvites, setLoadingInvites] = useState(true);
const [acceptingId, setAcceptingId] = useState<string | null>(null);
// 邀請的取得與接受/婉拒都在共用元件裡(user menu 的邀請對話框用的是同一份)。
const invitations = useUserInvitations();
// Until we know whether the user already belongs to an org, render nothing so
// we don't flash the create-org UI at members who shouldn't see it.
const [checkingMembership, setCheckingMembership] = useState(true);
// 掛載時取一次「現在」的快照:在 render 裡直接讀 Date.now() 是不純的,而邀請
// 的到期是以天為單位,不需要跟著時間跳動。
const [now] = useState(() => Date.now());

useEffect(() => {
(async () => {
Expand All @@ -57,41 +50,16 @@ export default function OnboardingPage() {
return;
}
setCheckingMembership(false);

const { data, error } = await authClient.organization.listUserInvitations();
if (!error && data) setInvites(data as unknown as Invite[]);
setLoadingInvites(false);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

// listUserInvitations 只 filter status === "pending",不管 expiresAt,而過期的邀請
// 在 DB 裡 status 仍然是 pending。所以後端回來的清單裡可能夾著一按就必定失敗
// (INVITATION_NOT_FOUND,訊息看起來像「找不到邀請」)的邀請,得在前端自己分開。
const expired = (inv: Invite) => new Date(inv.expiresAt).getTime() < now;
const acceptableInvites = invites.filter((i) => !expired(i));
const expiredInvites = invites.filter(expired);

async function enter(organizationId: string) {
await authClient.organization.setActive({ organizationId });
router.push("/dashboard");
router.refresh();
}

async function onAccept(inv: Invite) {
setAcceptingId(inv.id);
const { error } = await authClient.organization.acceptInvitation({
invitationId: inv.id,
});
if (error) {
setAcceptingId(null);
toast.error(error.message || t("invites.toast.acceptFailed"));
return;
}
toast.success(t("invites.toast.joined", { name: inv.organizationName }));
await enter(inv.organizationId);
}

async function onCreate(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = new FormData(e.currentTarget);
Expand All @@ -117,58 +85,22 @@ export default function OnboardingPage() {
);
}

const hasInvites = invitations.invites.length > 0;

return (
<div className="flex min-h-full flex-1 items-center justify-center p-6">
<div className="w-full max-w-sm space-y-4">
{!loadingInvites && invites.length > 0 && (
{!invitations.loading && hasInvites && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MailCheck className="size-5 text-muted-foreground" />
{t("invites.title")}
{ti("title")}
</CardTitle>
<CardDescription>{t("invites.description")}</CardDescription>
<CardDescription>{ti("description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{acceptableInvites.map((inv) => (
<div
key={inv.id}
className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"
>
<div className="min-w-0">
<div className="truncate font-medium">{inv.organizationName}</div>
<div className="text-xs text-muted-foreground">
{t("invites.role", { role: inv.role })}
</div>
</div>
<Button
size="sm"
onClick={() => onAccept(inv)}
disabled={acceptingId !== null}
>
{acceptingId === inv.id ? t("invites.accepting") : t("invites.accept")}
</Button>
</div>
))}
{/* 過期的只顯示、不給按鈕 —— 接受一定會失敗,給按鈕只是騙人。 */}
{expiredInvites.map((inv) => (
<div
key={inv.id}
className="flex items-center justify-between gap-3 rounded-md border border-dashed px-3 py-2 opacity-60"
>
<div className="min-w-0">
<div className="truncate font-medium text-muted-foreground">
{inv.organizationName}
</div>
<div className="text-xs text-muted-foreground">
{t("invites.expiredHint")}
</div>
</div>
<span className="shrink-0 text-xs text-muted-foreground">
{t("invites.expired")}
</span>
</div>
))}
<CardContent>
<PendingInvitations invitations={invitations} />
</CardContent>
</Card>
)}
Expand All @@ -179,7 +111,7 @@ export default function OnboardingPage() {
<Building2 className="size-5 text-muted-foreground" />
{t("create.title")}
</CardTitle>
{invites.length > 0 ? (
{hasInvites ? (
<CardDescription>{t("create.descriptionWithInvites")}</CardDescription>
) : null}
</CardHeader>
Expand Down
19 changes: 16 additions & 3 deletions src/components/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import {
SidebarMenuItem,
SidebarRail,
} from "@/components/ui/sidebar";
import { OrgSwitcher } from "@/components/org-switcher";
import { OrgSwitcher, type OrgOption } from "@/components/org-switcher";
import { UserMenu } from "@/components/user-menu";
import { LocaleSwitcher } from "@/components/locale-switcher";
import { Logo } from "@/components/logo";
Expand Down Expand Up @@ -113,7 +113,17 @@ const groups = [
{ label: "org", items: org },
] as const;

export function AppSidebar() {
/**
* initialOrganizations / initialActiveOrgId 由 dashboard 的 server layout 撈好傳進來,
* 讓組織切換器第一次繪製就有東西可顯示(純 client fetch 的話會先閃一格空白的下拉)。
*/
export function AppSidebar({
initialOrganizations,
initialActiveOrgId,
}: Readonly<{
initialOrganizations: OrgOption[];
initialActiveOrgId: string | null;
}>) {
const t = useTranslations("common");
const pathname = usePathname();

Expand All @@ -129,7 +139,10 @@ export function AppSidebar() {
<Sidebar>
<SidebarHeader>
<Logo className="px-1 py-1.5" />
<OrgSwitcher />
<OrgSwitcher
initialOrganizations={initialOrganizations}
initialActiveOrgId={initialActiveOrgId}
/>
</SidebarHeader>

<SidebarContent>
Expand Down
Loading