Skip to content

Commit

Permalink
fix: frontend bugs relating to redirects/tenants (#25)
Browse files Browse the repository at this point in the history
* fix: frontend bugs

- Adds a query param `tenant` to application routes
- Allows setting an environment variable `SERVER_AUTH_SET_EMAIL_VERIFIED` to true which automatically sets the email to verified for new signups, since most local installations won't have an email verification mechanism
- When a user is logged in, navigating to `/auth/login` or `/auth/register` will redirect to the application via the no-auth.tsx middleware
- When there are no events found, the backend will no longer respond with a `500`-level error, and will return 0 rows instead
  • Loading branch information
abelanger5 committed Dec 26, 2023
1 parent e857004 commit ce61ead
Show file tree
Hide file tree
Showing 20 changed files with 285 additions and 110 deletions.
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ SERVER_URL=https://app.dev.hatchet-tools.com
SERVER_AUTH_COOKIE_SECRETS="$(randstring 16) $(randstring 16)"
SERVER_AUTH_COOKIE_DOMAIN=app.dev.hatchet-tools.com
SERVER_AUTH_COOKIE_INSECURE=false
SERVER_AUTH_SET_EMAIL_VERIFIED=true
EOF
```

Expand Down
2 changes: 1 addition & 1 deletion api/v1/server/handlers/users/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func (u *UserService) UserCreate(ctx echo.Context, request gen.UserCreateRequest

createOpts := &repository.CreateUserOpts{
Email: string(request.Body.Email),
EmailVerified: repository.BoolPtr(false),
EmailVerified: repository.BoolPtr(u.config.Auth.SetEmailVerified),
Name: repository.StringPtr(request.Body.Name),
Password: *hashedPw,
}
Expand Down
4 changes: 4 additions & 0 deletions frontend/app/src/lib/api/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ export const queries = createQueryKeyStore({
queryKey: ['user:get'],
queryFn: async () => (await api.userGetCurrent()).data,
},
listTenantMemberships: {
queryKey: ['tenant-memberships:list'],
queryFn: async () => (await api.tenantMembershipsList()).data,
},
},
workflows: {
list: (tenant: string) => ({
Expand Down
115 changes: 107 additions & 8 deletions frontend/app/src/lib/atoms.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { atom } from 'jotai';
import { Tenant } from './api';
import { atom, useAtom } from 'jotai';
import { Tenant, queries } from './api';
import { useSearchParams } from 'react-router-dom';
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';

const getInitialValue = <T>(key: string): T | undefined => {
const item = localStorage.getItem(key);
Expand All @@ -11,14 +14,110 @@ const getInitialValue = <T>(key: string): T | undefined => {
return;
};

const currTenantKey = 'currTenant';
const lastTenantKey = 'lastTenant';

const currTenantAtomInit = atom(getInitialValue<Tenant>(currTenantKey));
const lastTenantAtomInit = atom(getInitialValue<Tenant>(lastTenantKey));

export const currTenantAtom = atom(
(get) => get(currTenantAtomInit),
export const lastTenantAtom = atom(
(get) => get(lastTenantAtomInit),
(_get, set, newVal: Tenant) => {
set(currTenantAtomInit, newVal);
localStorage.setItem(currTenantKey, JSON.stringify(newVal));
set(lastTenantAtomInit, newVal);
localStorage.setItem(lastTenantKey, JSON.stringify(newVal));
},
);

// search param sets the tenant, the last tenant set is used if the search param is empty,
// otherwise the first membership is used
export function useTenantContext(): [
Tenant | undefined,
(tenant: Tenant) => void,
] {
const [lastTenant, setLastTenant] = useAtom(lastTenantAtom);
const [searchParams, setSearchParams] = useSearchParams();
const [currTenant, setCurrTenant] = useState<Tenant>();

const listMembershipsQuery = useQuery({
...queries.user.listTenantMemberships,
});

const memberships = useMemo(() => {
return listMembershipsQuery.data?.rows || [];
}, [listMembershipsQuery]);

const computedCurrTenant = useMemo(() => {
const findTenant = (tenantId: string) => {
return memberships?.find((m) => m.tenant?.metadata.id === tenantId)
?.tenant;
};

const currTenantId = searchParams.get('tenant') || undefined;

if (currTenantId) {
const tenant = findTenant(currTenantId);

if (tenant) {
return tenant;
}
}

const lastTenantId = lastTenant?.metadata.id || undefined;

if (lastTenantId) {
const tenant = findTenant(lastTenantId);

if (tenant) {
return tenant;
}
}

const firstMembershipTenant = memberships?.[0]?.tenant;

return firstMembershipTenant;
}, [memberships, lastTenant?.metadata.id, searchParams]);

// sets the current tenant if the search param changes
useEffect(() => {
if (searchParams.get('tenant') !== currTenant?.metadata.id) {
const newTenant = memberships?.find(
(m) => m.tenant?.metadata.id === searchParams.get('tenant'),
)?.tenant;

if (newTenant) {
setCurrTenant(newTenant);
} else if (computedCurrTenant?.metadata.id) {
const newSearchParams = new URLSearchParams(searchParams);
newSearchParams.set('tenant', computedCurrTenant?.metadata.id);
setSearchParams(newSearchParams);
}
}
}, [
searchParams,
currTenant,
setCurrTenant,
memberships,
computedCurrTenant,
setSearchParams,
]);

// sets the current tenant to the initial tenant
useEffect(() => {
if (!currTenant && computedCurrTenant) {
setCurrTenant(computedCurrTenant);
}
}, [computedCurrTenant, currTenant, setCurrTenant]);

// keeps the current tenant in sync with the last tenant
useEffect(() => {
if (currTenant && lastTenant?.metadata.id !== currTenant?.metadata.id) {
setLastTenant(currTenant);
}
}, [lastTenant, currTenant, setLastTenant]);

const setTenant = (tenant: Tenant) => {
const newSearchParams = new URLSearchParams(searchParams);
newSearchParams.set('tenant', tenant.metadata.id);
setSearchParams(newSearchParams);
};

return [currTenant || computedCurrTenant, setTenant];
}
38 changes: 38 additions & 0 deletions frontend/app/src/pages/auth/no-auth.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { redirect } from 'react-router-dom';
import api from '@/lib/api';
import queryClient from '@/query-client';
import { AxiosError, isAxiosError } from 'axios';

const noAuthMiddleware = async () => {
try {
const user = await queryClient.fetchQuery({
queryKey: ['user:get:current'],
queryFn: async () => {
const res = await api.userGetCurrent();

return res.data;
},
});

if (user) {
throw redirect('/');
}
} catch (error) {
if (error instanceof Response) {
throw error;
} else if (isAxiosError(error)) {
const axiosErr = error as AxiosError;

if (axiosErr.response?.status === 403) {
return;
} else {
throw error;
}
}
}
};

export async function loader() {
await noAuthMiddleware();
return null;
}
15 changes: 10 additions & 5 deletions frontend/app/src/pages/main/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import {
redirect,
useLoaderData,
} from 'react-router-dom';
import api from '@/lib/api';
import api, { queries } from '@/lib/api';
import queryClient from '@/query-client';
import { useContextFromParent } from '@/lib/outlet';
import { Loading } from '@/components/ui/loading.tsx';
import { useQuery } from '@tanstack/react-query';

const authMiddleware = async (currentUrl: string) => {
try {
Expand Down Expand Up @@ -37,12 +38,12 @@ const authMiddleware = async (currentUrl: string) => {
}
};

const membershipsPopulator = async () => {
const membershipsPopulator = async (currentUrl: string) => {
const res = await api.tenantMembershipsList();

const memberships = res.data;

if (memberships.rows?.length === 0) {
if (memberships.rows?.length === 0 && !currentUrl.includes('/onboarding')) {
throw redirect('/onboarding/create-tenant');
}

Expand All @@ -51,21 +52,25 @@ const membershipsPopulator = async () => {

export async function loader({ request }: LoaderFunctionArgs) {

Check warning on line 53 in frontend/app/src/pages/main/auth.tsx

View workflow job for this annotation

GitHub Actions / lint

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
const user = await authMiddleware(request.url);
const memberships = await membershipsPopulator();
const memberships = await membershipsPopulator(request.url);
return {
user,
memberships,
};
}

export default function Auth() {
const listMembershipsQuery = useQuery({
...queries.user.listTenantMemberships,
});

const { user, memberships } = useLoaderData() as Awaited<
ReturnType<typeof loader>
>;

const ctx = useContextFromParent({
user,
memberships,
memberships: listMembershipsQuery.data?.rows || memberships,
});

if (!user || !memberships) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ import {
PopoverTrigger,
} from '@/components/ui/popover';
import { useMemo, useState } from 'react';
import { currTenantAtom } from '@/lib/atoms';
import { useQuery } from '@tanstack/react-query';
import { useAtom } from 'jotai';
import invariant from 'tiny-invariant';
import { DataTable } from '@/components/molecules/data-table/data-table';
import { TenantContextType } from '@/lib/outlet';
import { useOutletContext } from 'react-router-dom';

export const columns = ({
onRowClick,
Expand Down Expand Up @@ -100,7 +100,7 @@ export const columns = ({

// eslint-disable-next-line react-refresh/only-export-components
function WorkflowRunSummary({ event }: { event: Event }) {
const [tenant] = useAtom(currTenantAtom);
const { tenant } = useOutletContext<TenantContextType>();
invariant(tenant);

const [hoverCardOpen, setPopoverOpen] = useState<
Expand Down
25 changes: 14 additions & 11 deletions frontend/app/src/pages/main/events/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@ import api, {
queries,
} from '@/lib/api';
import invariant from 'tiny-invariant';
import { useAtom } from 'jotai';
import { currTenantAtom } from '@/lib/atoms';
import { FilterOption } from '@/components/molecules/data-table/data-table-toolbar';
import {
Dialog,
Expand All @@ -30,14 +28,15 @@ import {
} from '@/components/ui/dialog';
import { relativeDate } from '@/lib/utils';
import { Code } from '@/components/ui/code';
import { useSearchParams } from 'react-router-dom';
import { useOutletContext, useSearchParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import {
ArrowPathIcon,
ArrowPathRoundedSquareIcon,
} from '@heroicons/react/24/outline';
import { useApiError } from '@/lib/hooks';
import { Loading } from '@/components/ui/loading.tsx';
import { TenantContextType } from '@/lib/outlet';

export default function Events() {
return (
Expand All @@ -55,7 +54,7 @@ export default function Events() {

function EventsTable() {
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
const [tenant] = useAtom(currTenantAtom);
const { tenant } = useOutletContext<TenantContextType>();
const [searchParams, setSearchParams] = useSearchParams();
const [rotate, setRotate] = useState(false);
const { handleApiError } = useApiError({});
Expand All @@ -65,16 +64,20 @@ function EventsTable() {
useEffect(() => {
if (
selectedEvent &&
(!searchParams.get('eventId') ||
searchParams.get('eventId') !== selectedEvent.metadata.id)
(!searchParams.get('event') ||
searchParams.get('event') !== selectedEvent.metadata.id)
) {
setSearchParams({ eventId: selectedEvent.metadata.id });
const newSearchParams = new URLSearchParams(searchParams);
newSearchParams.set('event', selectedEvent.metadata.id);
setSearchParams(newSearchParams);
} else if (
!selectedEvent &&
searchParams.get('eventId') &&
searchParams.get('eventId') !== ''
searchParams.get('event') &&
searchParams.get('event') !== ''
) {
setSearchParams({});
const newSearchParams = new URLSearchParams(searchParams);
newSearchParams.delete('event');
setSearchParams(newSearchParams);
}
}, [selectedEvent, searchParams, setSearchParams]);

Expand Down Expand Up @@ -296,7 +299,7 @@ function EventDataSection({ event }: { event: Event }) {
}

function EventWorkflowRunsList({ event }: { event: Event }) {
const [tenant] = useAtom(currTenantAtom);
const { tenant } = useOutletContext<TenantContextType>();
invariant(tenant);

const listWorkflowRunsQuery = useQuery({
Expand Down
Loading

0 comments on commit ce61ead

Please sign in to comment.