Compare commits

...

1 commit

Author SHA1 Message Date
Nicolas Meienberger
f3eb57dbc7 chore: improve ssr data loading
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
2026-02-28 16:29:34 +01:00
8 changed files with 88 additions and 58 deletions

View file

@ -1,6 +1,6 @@
import { createIsomorphicFn } from "@tanstack/react-start"; import { createServerFn } from "@tanstack/react-start";
import { config } from "~/server/core/config"; import { config } from "~/server/core/config";
export const getOrigin = createIsomorphicFn() export const getOrigin = createServerFn().handler(() => {
.server(() => config.baseUrl) return config.baseUrl;
.client(() => window.location.origin); });

View file

@ -2,6 +2,7 @@ import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { Shield, ShieldAlert, Trash2 } from "lucide-react"; import { Shield, ShieldAlert, Trash2 } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { GetOrgMembersResponse } from "~/client/api-client";
import { import {
getOrgMembersOptions, getOrgMembersOptions,
removeOrgMemberMutation, removeOrgMemberMutation,
@ -24,13 +25,17 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~
import { useOrganizationContext } from "~/client/hooks/use-org-context"; import { useOrganizationContext } from "~/client/hooks/use-org-context";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
export function OrgMembersSection() { type OrgMembersSectionProps = {
initialMembers: GetOrgMembersResponse;
};
export function OrgMembersSection({ initialMembers }: OrgMembersSectionProps) {
const { activeMember } = useOrganizationContext(); const { activeMember } = useOrganizationContext();
const [memberToRemove, setMemberToRemove] = useState<{ id: string; name: string } | null>(null); const [memberToRemove, setMemberToRemove] = useState<{ id: string; name: string } | null>(null);
const orgMembersQuery = useSuspenseQuery({ const {
...getOrgMembersOptions(), data: { members },
}); } = useSuspenseQuery({ ...getOrgMembersOptions(), initialData: initialMembers });
const updateRole = useMutation({ const updateRole = useMutation({
...updateMemberRoleMutation(), ...updateMemberRoleMutation(),
@ -53,8 +58,6 @@ export function OrgMembersSection() {
}, },
}); });
const members = orgMembersQuery.data.members;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-md border"> <div className="rounded-md border">

View file

@ -32,19 +32,25 @@ import { formatDateWithMonth } from "~/client/lib/datetime";
import { getOrigin } from "~/client/functions/get-origin"; import { getOrigin } from "~/client/functions/get-origin";
import { authClient } from "~/client/lib/auth-client"; import { authClient } from "~/client/lib/auth-client";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
import { useServerFn } from "@tanstack/react-start";
import type { GetSsoSettingsResponse } from "~/client/api-client";
type InvitationRole = "member" | "admin" | "owner"; type InvitationRole = "member" | "admin" | "owner";
export function SsoSettingsSection() { type SsoSettingsSectionProps = {
const origin = getOrigin(); initialSettings: GetSsoSettingsResponse;
};
export function SsoSettingsSection({ initialSettings }: SsoSettingsSectionProps) {
const originQuery = useServerFn(getOrigin);
const { data } = useSuspenseQuery({ queryKey: ["app-origin"], queryFn: originQuery });
const navigate = useNavigate(); const navigate = useNavigate();
const { activeOrganization } = useOrganizationContext(); const { activeOrganization } = useOrganizationContext();
const [inviteEmail, setInviteEmail] = useState(""); const [inviteEmail, setInviteEmail] = useState("");
const [inviteRole, setInviteRole] = useState<InvitationRole>("member"); const [inviteRole, setInviteRole] = useState<InvitationRole>("member");
const ssoSettingsQuery = useSuspenseQuery({ const ssoSettingsQuery = useSuspenseQuery({ ...getSsoSettingsOptions(), initialData: initialSettings });
...getSsoSettingsOptions(),
});
const updateProviderAutoLinkingMutation = useMutation({ const updateProviderAutoLinkingMutation = useMutation({
...updateSsoProviderAutoLinkingMutation(), ...updateSsoProviderAutoLinkingMutation(),
@ -195,7 +201,7 @@ export function SsoSettingsSection() {
<Input <Input
type="text" type="text"
readOnly readOnly
value={`${origin}/api/auth/sso/callback/${provider.providerId}`} value={`${data}/api/auth/sso/callback/${provider.providerId}`}
className="h-8 max-w-62.5 font-mono text-xs text-muted-foreground" className="h-8 max-w-62.5 font-mono text-xs text-muted-foreground"
onClick={(e) => e.currentTarget.select()} onClick={(e) => e.currentTarget.select()}
/> />

View file

@ -24,12 +24,15 @@ import { useNavigate, useSearch } from "@tanstack/react-router";
import { SsoSettingsSection } from "../components/sso/sso-settings-section"; import { SsoSettingsSection } from "../components/sso/sso-settings-section";
import { OrgMembersSection } from "../components/org-members-section"; import { OrgMembersSection } from "../components/org-members-section";
import { useOrganizationContext } from "~/client/hooks/use-org-context"; import { useOrganizationContext } from "~/client/hooks/use-org-context";
import type { GetOrgMembersResponse, GetSsoSettingsResponse } from "~/client/api-client";
type Props = { type Props = {
appContext: AppContext; appContext: AppContext;
initialMembers: GetOrgMembersResponse;
initialSsoSettings: GetSsoSettingsResponse;
}; };
export function SettingsPage({ appContext }: Props) { export function SettingsPage({ appContext, initialMembers, initialSsoSettings }: Props) {
const [currentPassword, setCurrentPassword] = useState(""); const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
@ -296,7 +299,7 @@ export function SettingsPage({ appContext }: Props) {
<CardDescription className="mt-1.5">Manage organization members and roles</CardDescription> <CardDescription className="mt-1.5">Manage organization members and roles</CardDescription>
</div> </div>
<CardContent className="p-6"> <CardContent className="p-6">
<OrgMembersSection /> <OrgMembersSection initialMembers={initialMembers} />
</CardContent> </CardContent>
</Card> </Card>
@ -309,7 +312,7 @@ export function SettingsPage({ appContext }: Props) {
<CardDescription className="mt-1.5">Configure OIDC provider settings</CardDescription> <CardDescription className="mt-1.5">Configure OIDC provider settings</CardDescription>
</div> </div>
<CardContent className="p-6"> <CardContent className="p-6">
<SsoSettingsSection /> <SsoSettingsSection initialSettings={initialSsoSettings} />
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>

View file

@ -12,6 +12,7 @@ import { VolumeIcon } from "~/client/components/volume-icon";
import { listVolumesOptions } from "~/client/api-client/@tanstack/react-query.gen"; import { listVolumesOptions } from "~/client/api-client/@tanstack/react-query.gen";
import type { VolumeStatus } from "~/client/lib/types"; import type { VolumeStatus } from "~/client/lib/types";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { cn } from "~/client/lib/utils";
const getVolumeStatusVariant = (status: VolumeStatus): "success" | "neutral" | "error" | "warning" => { const getVolumeStatusVariant = (status: VolumeStatus): "success" | "neutral" | "error" | "warning" => {
const statusMap = { const statusMap = {
@ -119,38 +120,35 @@ export function VolumesPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{hasNoFilteredVolumes ? ( <TableRow className={cn({ hidden: !hasNoFilteredVolumes })}>
<TableRow> <TableCell colSpan={4} className="text-center py-12">
<TableCell colSpan={4} className="text-center py-12"> <div className="flex flex-col items-center gap-3">
<div className="flex flex-col items-center gap-3"> <p className="text-muted-foreground">No volumes match your filters.</p>
<p className="text-muted-foreground">No volumes match your filters.</p> <Button onClick={clearFilters} variant="outline" size="sm">
<Button onClick={clearFilters} variant="outline" size="sm"> <RotateCcw className="h-4 w-4 mr-2" />
<RotateCcw className="h-4 w-4 mr-2" /> Clear filters
Clear filters </Button>
</Button> </div>
</div> </TableCell>
</TableRow>
{filteredVolumes.map((volume) => (
<TableRow
key={volume.name}
className="hover:bg-white/2 hover:cursor-pointer transition-colors h-12"
onClick={() => navigate({ to: `/volumes/${volume.shortId}` })}
>
<TableCell className="font-medium font-mono text-strong-accent">{volume.name}</TableCell>
<TableCell className="font-mono text-muted-foreground">
<VolumeIcon backend={volume.type} />
</TableCell>
<TableCell className="text-center font-mono">
<StatusDot
variant={getVolumeStatusVariant(volume.status)}
label={volume.status[0].toUpperCase() + volume.status.slice(1)}
/>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ))}
filteredVolumes.map((volume) => (
<TableRow
key={volume.name}
className="hover:bg-white/2 hover:cursor-pointer transition-colors border-l-2 border-r-2 border-transparent hover:border-white/10 h-12"
onClick={() => navigate({ to: `/volumes/${volume.shortId}` })}
>
<TableCell className="font-medium font-mono text-strong-accent">{volume.name}</TableCell>
<TableCell className="font-mono text-muted-foreground">
<VolumeIcon backend={volume.type} />
</TableCell>
<TableCell className="text-center font-mono">
<StatusDot
variant={getVolumeStatusVariant(volume.status)}
label={volume.status[0].toUpperCase() + volume.status.slice(1)}
/>
</TableCell>
</TableRow>
))
)}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>

View file

@ -3,7 +3,7 @@ import { type } from "arktype";
import { fetchUser } from "../route"; import { fetchUser } from "../route";
import type { AppContext } from "~/context"; import type { AppContext } from "~/context";
import { AdminPage } from "~/client/modules/admin/routes/admin-page"; import { AdminPage } from "~/client/modules/admin/routes/admin-page";
import { getAdminUsersOptions } from "~/client/api-client/@tanstack/react-query.gen"; import { getAdminUsersOptions, getRegistrationStatusOptions } from "~/client/api-client/@tanstack/react-query.gen";
export const Route = createFileRoute("/(dashboard)/admin/")({ export const Route = createFileRoute("/(dashboard)/admin/")({
validateSearch: type({ tab: "string?" }), validateSearch: type({ tab: "string?" }),
@ -15,7 +15,10 @@ export const Route = createFileRoute("/(dashboard)/admin/")({
throw redirect({ to: "/settings" }); throw redirect({ to: "/settings" });
} }
await context.queryClient.ensureQueryData(getAdminUsersOptions()); await Promise.all([
context.queryClient.ensureQueryData({ ...getAdminUsersOptions() }),
context.queryClient.ensureQueryData({ ...getRegistrationStatusOptions() }),
]);
return authContext as AppContext; return authContext as AppContext;
}, },

View file

@ -5,6 +5,7 @@ import { Layout } from "~/client/components/layout";
import { SIDEBAR_COOKIE_NAME } from "~/client/components/ui/sidebar"; import { SIDEBAR_COOKIE_NAME } from "~/client/components/ui/sidebar";
import { authMiddleware } from "~/middleware/auth"; import { authMiddleware } from "~/middleware/auth";
import { auth } from "~/server/lib/auth"; import { auth } from "~/server/lib/auth";
import { getOrganizationContext } from "~/server/lib/functions/organization-context";
import { authService } from "~/server/modules/auth/auth.service"; import { authService } from "~/server/modules/auth/auth.service";
export const fetchUser = createServerFn({ method: "GET" }).handler(async () => { export const fetchUser = createServerFn({ method: "GET" }).handler(async () => {
@ -24,8 +25,15 @@ export const Route = createFileRoute("/(dashboard)")({
server: { server: {
middleware: [authMiddleware], middleware: [authMiddleware],
}, },
loader: async () => { loader: async ({ context }) => {
const authContext = await fetchUser(); const [authContext] = await Promise.all([
fetchUser(),
context.queryClient.ensureQueryData({
queryKey: ["organization-context"],
queryFn: () => getOrganizationContext(),
}),
]);
return authContext; return authContext;
}, },
}); });

View file

@ -3,13 +3,21 @@ import { fetchUser } from "../route";
import type { AppContext } from "~/context"; import type { AppContext } from "~/context";
import { SettingsPage } from "~/client/modules/settings/routes/settings"; import { SettingsPage } from "~/client/modules/settings/routes/settings";
import { type } from "arktype"; import { type } from "arktype";
import { getOrgMembersOptions, getSsoSettingsOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { getOrigin } from "~/client/functions/get-origin";
export const Route = createFileRoute("/(dashboard)/settings/")({ export const Route = createFileRoute("/(dashboard)/settings/")({
component: RouteComponent, component: RouteComponent,
validateSearch: type({ tab: "string?" }), validateSearch: type({ tab: "string?" }),
loader: async () => { loader: async ({ context }) => {
const authContext = await fetchUser(); const [authContext, org, members] = await Promise.all([
return authContext as AppContext; fetchUser(),
context.queryClient.ensureQueryData({ ...getSsoSettingsOptions() }),
context.queryClient.ensureQueryData({ ...getOrgMembersOptions() }),
context.queryClient.ensureQueryData({ queryKey: ["app-origin"], queryFn: () => getOrigin() }),
]);
return { authContext: authContext as AppContext, org, members };
}, },
staticData: { staticData: {
breadcrumb: () => [{ label: "Settings" }], breadcrumb: () => [{ label: "Settings" }],
@ -17,6 +25,7 @@ export const Route = createFileRoute("/(dashboard)/settings/")({
}); });
function RouteComponent() { function RouteComponent() {
const appContext = Route.useLoaderData(); const { authContext, org, members } = Route.useLoaderData();
return <SettingsPage appContext={appContext} />;
return <SettingsPage appContext={authContext} initialMembers={members} initialSsoSettings={org} />;
} }