diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 5212eca0..a25594c7 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,6 +1,6 @@ import { redirect } from 'next/navigation'; import { headers } from 'next/headers'; -import { eq } from 'drizzle-orm'; +import { and, eq } from 'drizzle-orm'; import { auth } from '@/lib/auth'; import { db } from '@/lib/db'; @@ -11,6 +11,13 @@ import { userPortRoles, userProfiles } from '@/lib/db/schema/users'; * Plain `/dashboard` lands users into their default port's dashboard. Used * by post-login redirects and any code that doesn't yet know the active * port slug. + * + * Resolution order: + * 1. `preferences.defaultPortId` — last port the user worked in, written + * by the port-switcher. Only honoured if the user still has access + * (preference may be stale after a role revoke or port archive). + * 2. Super-admin → first port alphabetically. Other users → first + * `user_port_roles` row. */ export default async function DashboardRedirectPage() { const session = await auth.api.getSession({ headers: await headers() }); @@ -20,16 +27,35 @@ export default async function DashboardRedirectPage() { where: eq(userProfiles.userId, session.user.id), }); + const lastPortId = (profile?.preferences as { defaultPortId?: string } | null)?.defaultPortId; let slug: string | undefined; - if (profile?.isSuperAdmin) { - const first = await db.query.ports.findFirst({ orderBy: portsTable.name }); - slug = first?.slug; - } else { - const role = await db.query.userPortRoles.findFirst({ - where: eq(userPortRoles.userId, session.user.id), - with: { port: true }, - }); - slug = role?.port.slug; + + if (lastPortId) { + // Verify access before honouring the preference — a stale id (port + // archived, role revoked) shouldn't strand the user on a 403. + if (profile?.isSuperAdmin) { + const port = await db.query.ports.findFirst({ where: eq(portsTable.id, lastPortId) }); + slug = port?.slug; + } else { + const role = await db.query.userPortRoles.findFirst({ + where: and(eq(userPortRoles.userId, session.user.id), eq(userPortRoles.portId, lastPortId)), + with: { port: true }, + }); + slug = role?.port.slug; + } + } + + if (!slug) { + if (profile?.isSuperAdmin) { + const first = await db.query.ports.findFirst({ orderBy: portsTable.name }); + slug = first?.slug; + } else { + const role = await db.query.userPortRoles.findFirst({ + where: eq(userPortRoles.userId, session.user.id), + with: { port: true }, + }); + slug = role?.port.slug; + } } if (!slug) redirect('/login?error=no-port-access'); diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 00000000..beea0c67 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,13 @@ +import { redirect } from 'next/navigation'; + +/** + * Root `/` has no UI of its own — every authenticated surface lives under + * a port slug. Forward to `/dashboard`, which resolves the user's default + * port and redirects again to `//dashboard`. Without this, a + * post-login redirect of `redirect=/` (set by middleware when the user + * originally hit `/`) lands on an empty 404. + */ +export default function RootPage() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + redirect('/dashboard' as any); +} diff --git a/src/components/layout/user-menu.tsx b/src/components/layout/user-menu.tsx index 27b90c71..e442c49c 100644 --- a/src/components/layout/user-menu.tsx +++ b/src/components/layout/user-menu.tsx @@ -19,6 +19,8 @@ import { LogOut, Settings, Bell, Check, Building2 } from 'lucide-react'; import { type ReactNode } from 'react'; import { useUIStore } from '@/stores/ui-store'; +import { signOut } from '@/lib/auth/client'; +import { apiFetch } from '@/lib/api/client'; import { DropdownMenu, DropdownMenuContent, @@ -61,6 +63,16 @@ export function UserMenu({ trigger, align = 'end', user, ports }: UserMenuProps) // All cached queries are port-scoped - invalidate so they refetch with // the new X-Port-Id header. queryClient.invalidateQueries(); + // Remember the choice so the next login lands here automatically. + // Fire-and-forget — failure shouldn't block the navigation, and any + // stale value is harmless (the post-login resolver verifies access + // before honouring it). + void apiFetch('/api/v1/me', { + method: 'PATCH', + body: { preferences: { defaultPortId: port.id } }, + }).catch(() => { + /* silent — best-effort */ + }); // eslint-disable-next-line @typescript-eslint/no-explicit-any router.push(`/${port.slug}/dashboard` as any); } @@ -122,7 +134,16 @@ export function UserMenu({ trigger, align = 'end', user, ports }: UserMenuProps) router.push('/api/auth/sign-out')} + // `router.push` to /api/auth/sign-out issued a GET against + // better-auth's POST-only endpoint. Safari (and other browsers + // serving JSON without a matching renderer) treat that as a + // file download — the response landed on disk as a `sign_out` + // file instead of signing the user out. Call the auth client + // directly to issue a proper POST, then redirect to /login. + onClick={async () => { + await signOut(); + router.push('/login'); + }} > Sign Out diff --git a/src/providers/port-provider.tsx b/src/providers/port-provider.tsx index cd38adc0..ff9f8795 100644 --- a/src/providers/port-provider.tsx +++ b/src/providers/port-provider.tsx @@ -1,9 +1,10 @@ 'use client'; -import { createContext, useContext, useEffect, type ReactNode } from 'react'; +import { createContext, useContext, useEffect, useRef, type ReactNode } from 'react'; import { useParams } from 'next/navigation'; import { useUIStore } from '@/stores/ui-store'; +import { apiFetch } from '@/lib/api/client'; import type { Port } from '@/lib/db/schema/ports'; interface PortContextValue { @@ -47,6 +48,26 @@ export function PortProvider({ children, ports, defaultPortId }: PortProviderPro } }, [currentPort, currentPortId, currentPortSlug, setPort]); + // Remember the last port the user landed on (URL-derived or + // explicit-switch) so the next login routes here automatically. Tracked + // in a ref-keyed dedupe so we only PATCH when the active port actually + // changes — re-renders inside the same port don't write. Fire-and-forget; + // a transient network failure shouldn't block navigation, and the + // post-login resolver verifies access so a stale value can't strand the + // user on a 403. + const lastPersistedPortIdRef = useRef(null); + useEffect(() => { + if (!currentPort) return; + if (lastPersistedPortIdRef.current === currentPort.id) return; + lastPersistedPortIdRef.current = currentPort.id; + void apiFetch('/api/v1/me', { + method: 'PATCH', + body: { preferences: { defaultPortId: currentPort.id } }, + }).catch(() => { + /* silent — best-effort */ + }); + }, [currentPort]); + return (