MED-109: add doctor role and basic view (#45)
* MED-109: add doctor role and basic view * add role to accounts * remove old super admin and doctor sql
This commit is contained in:
63
app/doctor/_components/doctor-sidebar.tsx
Normal file
63
app/doctor/_components/doctor-sidebar.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { UserWorkspace } from '@/app/home/(user)/_lib/server/load-user-workspace';
|
||||
import { LayoutDashboard } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
useSidebar,
|
||||
} from '@kit/ui/shadcn-sidebar';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { AppLogo } from '~/components/app-logo';
|
||||
import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container';
|
||||
|
||||
export function DoctorSidebar({
|
||||
accounts,
|
||||
}: {
|
||||
accounts: UserWorkspace['accounts'];
|
||||
}) {
|
||||
const path = usePathname();
|
||||
const { open } = useSidebar();
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader className={'m-2'}>
|
||||
<AppLogo href={'/doctor'} className="max-w-full" compact={!open} />
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>
|
||||
<Trans i18nKey="common:doctor" />
|
||||
</SidebarGroupLabel>
|
||||
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuButton isActive={path === '/doctor'} asChild>
|
||||
<Link className={'flex gap-2.5'} href={'/doctor'}>
|
||||
<LayoutDashboard className={'h-4'} />
|
||||
<span>Dashboard</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<ProfileAccountDropdownContainer accounts={accounts} />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
28
app/doctor/_components/mobile-navigation.tsx
Normal file
28
app/doctor/_components/mobile-navigation.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Menu } from 'lucide-react';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kit/ui/dropdown-menu';
|
||||
import pathsConfig from '../../../config/paths.config';
|
||||
|
||||
export function DoctorMobileNavigation() {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Menu className={'h-8 w-8'} />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<Link href={pathsConfig.app.doctor}>Home</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
47
app/doctor/layout.tsx
Normal file
47
app/doctor/layout.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { use } from 'react';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
|
||||
import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
|
||||
|
||||
|
||||
import { loadUserWorkspace } from '../home/(user)/_lib/server/load-user-workspace';
|
||||
import { DoctorSidebar } from './_components/doctor-sidebar';
|
||||
import { DoctorMobileNavigation } from './_components/mobile-navigation';
|
||||
|
||||
export const metadata = {
|
||||
title: `Doctor`,
|
||||
};
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function DoctorLayout(props: React.PropsWithChildren) {
|
||||
const state = use(getLayoutState());
|
||||
const workspace = use(loadUserWorkspace());
|
||||
|
||||
return (
|
||||
<SidebarProvider defaultOpen={state.open}>
|
||||
<Page style={'sidebar'}>
|
||||
<PageNavigation>
|
||||
<DoctorSidebar accounts={workspace.accounts} />
|
||||
</PageNavigation>
|
||||
|
||||
<PageMobileNavigation>
|
||||
<DoctorMobileNavigation />
|
||||
</PageMobileNavigation>
|
||||
|
||||
{props.children}
|
||||
</Page>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
async function getLayoutState() {
|
||||
const cookieStore = await cookies();
|
||||
const sidebarOpenCookie = cookieStore.get('sidebar:state');
|
||||
|
||||
return {
|
||||
open: sidebarOpenCookie?.value !== 'true',
|
||||
};
|
||||
}
|
||||
3
app/doctor/loading.tsx
Normal file
3
app/doctor/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GlobalLoader } from '@kit/ui/global-loader';
|
||||
|
||||
export default GlobalLoader;
|
||||
17
app/doctor/page.tsx
Normal file
17
app/doctor/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { DoctorGuard } from '@kit/doctor/components/doctor-guard';
|
||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
function DoctorPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader description={<Trans i18nKey="common:doctor" />} />
|
||||
|
||||
<PageBody>
|
||||
<div>TBD</div>
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DoctorGuard(DoctorPage);
|
||||
@@ -11,6 +11,9 @@ import pathsConfig from '~/config/paths.config';
|
||||
|
||||
const paths = {
|
||||
home: pathsConfig.app.home,
|
||||
admin: pathsConfig.app.admin,
|
||||
doctor: pathsConfig.app.doctor,
|
||||
personalAccountSettings: pathsConfig.app.personalAccountSettings
|
||||
};
|
||||
|
||||
const features = {
|
||||
@@ -30,7 +33,7 @@ export function ProfileAccountDropdownContainer(props: {
|
||||
label: string | null;
|
||||
value: string | null;
|
||||
image?: string | null;
|
||||
}[]
|
||||
}[];
|
||||
}) {
|
||||
const signOut = useSignOut();
|
||||
const user = useUser(props.user);
|
||||
|
||||
@@ -30,6 +30,8 @@ const PathsSchema = z.object({
|
||||
accountMembers: z.string().min(1),
|
||||
accountBillingReturn: z.string().min(1),
|
||||
joinTeam: z.string().min(1),
|
||||
doctor: z.string().min(1),
|
||||
admin: z.string().min(1),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -63,6 +65,8 @@ const pathsConfig = PathsSchema.parse({
|
||||
analysisResults: '/home/analysis-results',
|
||||
orderAnalysis: '/home/order-analysis',
|
||||
orderHealthAnalysis: '/home/order-health-analysis',
|
||||
doctor: '/doctor',
|
||||
admin: '/admin',
|
||||
},
|
||||
} satisfies z.infer<typeof PathsSchema>);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ const INTERNAL_PACKAGES = [
|
||||
'@kit/auth',
|
||||
'@kit/accounts',
|
||||
'@kit/admin',
|
||||
'@kit/doctor',
|
||||
'@kit/team-accounts',
|
||||
'@kit/shared',
|
||||
'@kit/supabase',
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@kit/accounts": "workspace:*",
|
||||
"@kit/admin": "workspace:*",
|
||||
"@kit/doctor": "workspace:*",
|
||||
"@kit/analytics": "workspace:*",
|
||||
"@kit/auth": "workspace:*",
|
||||
"@kit/billing": "workspace:*",
|
||||
|
||||
@@ -6,7 +6,14 @@ import Link from 'next/link';
|
||||
|
||||
import type { User } from '@supabase/supabase-js';
|
||||
|
||||
import { ChevronsUpDown, Home, LogOut, Shield, UserCircle } from 'lucide-react';
|
||||
import {
|
||||
ChevronsUpDown,
|
||||
Cross,
|
||||
Home,
|
||||
LogOut,
|
||||
Shield,
|
||||
UserCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@kit/ui/avatar';
|
||||
import {
|
||||
@@ -44,16 +51,21 @@ export function PersonalAccountDropdown({
|
||||
id: string | null;
|
||||
name: string | null;
|
||||
picture_url: string | null;
|
||||
application_role: string;
|
||||
};
|
||||
accounts: {
|
||||
label: string | null;
|
||||
value: string | null;
|
||||
image?: string | null;
|
||||
application_role: string;
|
||||
}[];
|
||||
signOutRequested: () => unknown;
|
||||
|
||||
paths: {
|
||||
home: string;
|
||||
admin: string;
|
||||
doctor: string;
|
||||
personalAccountSettings: string;
|
||||
};
|
||||
|
||||
features: {
|
||||
@@ -64,10 +76,7 @@ export function PersonalAccountDropdown({
|
||||
|
||||
className?: string;
|
||||
}) {
|
||||
const { data: personalAccountData } = usePersonalAccountData(
|
||||
user.id,
|
||||
account,
|
||||
);
|
||||
const { data: personalAccountData } = usePersonalAccountData(user.id);
|
||||
|
||||
const signedInAsLabel = useMemo(() => {
|
||||
const email = user?.email ?? undefined;
|
||||
@@ -79,15 +88,25 @@ export function PersonalAccountDropdown({
|
||||
const displayName =
|
||||
personalAccountData?.name ?? account?.name ?? user?.email ?? '';
|
||||
|
||||
const isSuperAdmin = useMemo(() => {
|
||||
const hasTotpFactor = useMemo(() => {
|
||||
const factors = user?.factors ?? [];
|
||||
const hasAdminRole = user?.app_metadata.role === 'super-admin';
|
||||
const hasTotpFactor = factors.some(
|
||||
return factors.some(
|
||||
(factor) => factor.factor_type === 'totp' && factor.status === 'verified',
|
||||
);
|
||||
}, [user.factors]);
|
||||
|
||||
const isSuperAdmin = useMemo(() => {
|
||||
const hasAdminRole =
|
||||
personalAccountData?.application_role === 'super_admin';
|
||||
|
||||
return hasAdminRole && hasTotpFactor;
|
||||
}, [user]);
|
||||
}, [user, personalAccountData, hasTotpFactor]);
|
||||
|
||||
const isDoctor = useMemo(() => {
|
||||
const hasDoctorRole = personalAccountData?.application_role === 'doctor';
|
||||
|
||||
return hasDoctorRole && hasTotpFactor;
|
||||
}, [user, personalAccountData, hasTotpFactor]);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -177,7 +196,7 @@ export function PersonalAccountDropdown({
|
||||
<DropdownMenuItem key={account.value} asChild>
|
||||
<Link
|
||||
className={'s-full flex cursor-pointer items-center space-x-2'}
|
||||
href={`/home/${account.value}`}
|
||||
href={`${paths.home}/${account.value}`}
|
||||
>
|
||||
<div className={'flex items-center'}>
|
||||
<Avatar className={'h-5 w-5 rounded-xs ' + account.image}>
|
||||
@@ -209,7 +228,7 @@ export function PersonalAccountDropdown({
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
className={'s-full flex cursor-pointer items-center space-x-2'}
|
||||
href={'/home/settings'}
|
||||
href={paths.personalAccountSettings}
|
||||
>
|
||||
<UserCircle className={'h-5'} />
|
||||
|
||||
@@ -227,7 +246,7 @@ export function PersonalAccountDropdown({
|
||||
className={
|
||||
's-full flex cursor-pointer items-center space-x-2 text-yellow-700 dark:text-yellow-500'
|
||||
}
|
||||
href={'/admin'}
|
||||
href={paths.admin}
|
||||
>
|
||||
<Shield className={'h-5'} />
|
||||
|
||||
@@ -236,6 +255,25 @@ export function PersonalAccountDropdown({
|
||||
</DropdownMenuItem>
|
||||
</If>
|
||||
|
||||
<If condition={isDoctor}>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
className={
|
||||
's-full flex cursor-pointer items-center space-x-2 text-yellow-700 dark:text-yellow-500'
|
||||
}
|
||||
href={paths.doctor}
|
||||
>
|
||||
<Cross className={'h-5'} />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey="common:doctor" />
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</If>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<If condition={features.enableThemeToggle}>
|
||||
|
||||
@@ -4,9 +4,13 @@ import { Database } from '@kit/supabase/database';
|
||||
|
||||
import { UserAnalysis } from '../types/accounts';
|
||||
|
||||
export type AccountWithParams = Database['medreport']['Tables']['accounts']['Row'] & {
|
||||
account_params: Pick<Database['medreport']['Tables']['account_params']['Row'], 'weight' | 'height'> | null;
|
||||
};
|
||||
export type AccountWithParams =
|
||||
Database['medreport']['Tables']['accounts']['Row'] & {
|
||||
account_params: Pick<
|
||||
Database['medreport']['Tables']['account_params']['Row'],
|
||||
'weight' | 'height'
|
||||
> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Class representing an API for interacting with user accounts.
|
||||
@@ -79,7 +83,8 @@ class AccountsApi {
|
||||
accounts (
|
||||
name,
|
||||
slug,
|
||||
picture_url
|
||||
picture_url,
|
||||
application_role
|
||||
)
|
||||
`,
|
||||
)
|
||||
@@ -95,6 +100,7 @@ class AccountsApi {
|
||||
label: accounts.name,
|
||||
value: accounts.slug,
|
||||
image: accounts.picture_url,
|
||||
application_role: accounts.application_role,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -209,11 +215,12 @@ class AccountsApi {
|
||||
return null;
|
||||
}
|
||||
|
||||
return analysisResponses
|
||||
.map((r) => ({
|
||||
...r,
|
||||
elements: analysisResponseElements.filter((e) => e.analysis_response_id === r.id),
|
||||
}));
|
||||
return analysisResponses.map((r) => ({
|
||||
...r,
|
||||
elements: analysisResponseElements.filter(
|
||||
(e) => e.analysis_response_id === r.id,
|
||||
),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTransition } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
@@ -11,6 +13,7 @@ import { z } from 'zod';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Checkbox } from '@kit/ui/checkbox';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -32,7 +35,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@kit/ui/select';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { updateRoleAction } from '../lib/server/admin-server-actions';
|
||||
import { AdminDeleteAccountDialog } from './admin-delete-account-dialog';
|
||||
import { AdminDeleteUserDialog } from './admin-delete-user-dialog';
|
||||
import { AdminImpersonateUserDialog } from './admin-impersonate-user-dialog';
|
||||
@@ -204,6 +210,39 @@ function getColumns(): ColumnDef<Account>[] {
|
||||
header: 'Updated At',
|
||||
accessorKey: 'updated_at',
|
||||
},
|
||||
{
|
||||
id: 'isDoctor',
|
||||
header: 'Doctor',
|
||||
cell: ({ row }) => {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleToggle = () => {
|
||||
startTransition(async () => {
|
||||
const isDoctor = row.original.application_role === 'doctor';
|
||||
const newRole = isDoctor ? 'user' : 'doctor';
|
||||
|
||||
const promise = updateRoleAction({
|
||||
accountId: row.original.id,
|
||||
role: newRole,
|
||||
});
|
||||
|
||||
toast.promise(() => promise, {
|
||||
success: <Trans i18nKey={'account:updateRoleSuccess'} />,
|
||||
error: <Trans i18nKey={'account:updateRoleError'} />,
|
||||
loading: <Trans i18nKey={'account:updateRoleLoading'} />,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
checked={row.original.application_role === 'doctor'}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={isPending}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
DeleteUserSchema,
|
||||
ImpersonateUserSchema,
|
||||
ReactivateUserSchema,
|
||||
UpdateAccountRoleSchema,
|
||||
} from './schema/admin-actions.schema';
|
||||
import { CreateCompanySchema } from './schema/create-company.schema';
|
||||
import { CreateUserSchema } from './schema/create-user.schema';
|
||||
@@ -273,6 +274,32 @@ export const createCompanyAccountAction = enhanceAction(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @name updateRoleAction
|
||||
* @description Update application role for user
|
||||
*/
|
||||
export const updateRoleAction = adminAction(
|
||||
enhanceAction(
|
||||
async ({ accountId, role }) => {
|
||||
const service = getAdminAccountsService();
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info({ accountId }, `Super Admin is updating account role...`);
|
||||
|
||||
await service.updateRole(accountId, role);
|
||||
|
||||
logger.info({ accountId }, `Successfully changed role`);
|
||||
|
||||
revalidateAdmin();
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
schema: UpdateAccountRoleSchema,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
function revalidateAdmin() {
|
||||
revalidatePath('/admin', 'layout');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
const ConfirmationSchema = z.object({
|
||||
confirmation: z.custom<string>((value) => value === 'CONFIRM'),
|
||||
});
|
||||
@@ -16,3 +18,10 @@ export const DeleteUserSchema = UserIdSchema;
|
||||
export const DeleteAccountSchema = ConfirmationSchema.extend({
|
||||
accountId: z.string().uuid(),
|
||||
});
|
||||
|
||||
type ApplicationRoleType =
|
||||
Database['medreport']['Tables']['accounts']['Row']['application_role'];
|
||||
export const UpdateAccountRoleSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
role: z.string() as z.ZodType<ApplicationRoleType>,
|
||||
});
|
||||
|
||||
@@ -22,4 +22,19 @@ class AdminAccountsService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateRole(
|
||||
accountId: string,
|
||||
role: Database['medreport']['Tables']['accounts']['Row']['application_role'],
|
||||
) {
|
||||
const { error } = await this.adminClient
|
||||
.schema('medreport')
|
||||
.from('accounts')
|
||||
.update({ application_role: role })
|
||||
.eq('id', accountId);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
3
packages/features/doctor/eslint.config.mjs
Normal file
3
packages/features/doctor/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import eslintConfigBase from '@kit/eslint-config/base.js';
|
||||
|
||||
export default eslintConfigBase;
|
||||
43
packages/features/doctor/package.json
Normal file
43
packages/features/doctor/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@kit/doctor",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"format": "prettier --check \"**/*.{ts,tsx}\"",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"devDependencies": {
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/next": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@makerkit/data-loader-supabase-core": "^0.0.10",
|
||||
"@makerkit/data-loader-supabase-nextjs": "^1.2.5",
|
||||
"@supabase/supabase-js": "2.49.4",
|
||||
"@tanstack/react-query": "5.76.1",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/react": "19.1.4",
|
||||
"lucide-react": "^0.510.0",
|
||||
"next": "15.3.2",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./components/*": "./src/components/*.tsx"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
28
packages/features/doctor/src/components/doctor-guard.tsx
Normal file
28
packages/features/doctor/src/components/doctor-guard.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { isDoctor } from '../lib/server/utils/is-doctor';
|
||||
|
||||
|
||||
type LayoutOrPageComponent<Params> = React.ComponentType<Params>;
|
||||
|
||||
/**
|
||||
* DoctorGuard is a server component wrapper that checks if the user is a doctor before rendering the component.
|
||||
* If the user is not a doctor, we redirect to a 404.
|
||||
* @param Component - The Page or Layout component to wrap
|
||||
*/
|
||||
export function DoctorGuard<Params extends object>(
|
||||
Component: LayoutOrPageComponent<Params>,
|
||||
) {
|
||||
return async function DoctorGuardServerComponentWrapper(params: Params) {
|
||||
const client = getSupabaseServerClient();
|
||||
const isUserDoctor = await isDoctor(client);
|
||||
|
||||
// if the user is not a super-admin, we redirect to a 404
|
||||
if (!isUserDoctor) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <Component {...params} />;
|
||||
};
|
||||
}
|
||||
1
packages/features/doctor/src/index.ts
Normal file
1
packages/features/doctor/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './lib/server/utils/is-doctor';
|
||||
24
packages/features/doctor/src/lib/server/utils/is-doctor.ts
Normal file
24
packages/features/doctor/src/lib/server/utils/is-doctor.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
/**
|
||||
* @name isDoctor
|
||||
* @description Check if the current user is a doctor.
|
||||
* @param client
|
||||
*/
|
||||
export async function isDoctor(client: SupabaseClient<Database>) {
|
||||
try {
|
||||
const { data, error } = await client
|
||||
.schema('medreport')
|
||||
.rpc('is_doctor');
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
10
packages/features/doctor/tsconfig.json
Normal file
10
packages/features/doctor/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -228,7 +228,6 @@ export function NotificationsPopover(params: {
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
console.log('test');
|
||||
setNotifications((existing) => {
|
||||
return existing.filter(
|
||||
(existingNotification) =>
|
||||
|
||||
@@ -264,6 +264,7 @@ export type Database = {
|
||||
}
|
||||
accounts: {
|
||||
Row: {
|
||||
application_role: Database["medreport"]["Enums"]["application_role"]
|
||||
city: string | null
|
||||
created_at: string | null
|
||||
created_by: string | null
|
||||
@@ -284,6 +285,7 @@ export type Database = {
|
||||
updated_by: string | null
|
||||
}
|
||||
Insert: {
|
||||
application_role?: Database["medreport"]["Enums"]["application_role"]
|
||||
city?: string | null
|
||||
created_at?: string | null
|
||||
created_by?: string | null
|
||||
@@ -304,6 +306,7 @@ export type Database = {
|
||||
updated_by?: string | null
|
||||
}
|
||||
Update: {
|
||||
application_role?: Database["medreport"]["Enums"]["application_role"]
|
||||
city?: string | null
|
||||
created_at?: string | null
|
||||
created_by?: string | null
|
||||
@@ -1633,6 +1636,7 @@ export type Database = {
|
||||
create_team_account: {
|
||||
Args: { account_name: string; new_personal_code: string }
|
||||
Returns: {
|
||||
application_role: Database["medreport"]["Enums"]["application_role"]
|
||||
city: string | null
|
||||
created_at: string | null
|
||||
created_by: string | null
|
||||
@@ -1761,6 +1765,10 @@ export type Database = {
|
||||
Args: { account_slug: string }
|
||||
Returns: boolean
|
||||
}
|
||||
is_doctor: {
|
||||
Args: Record<PropertyKey, never>
|
||||
Returns: boolean
|
||||
}
|
||||
is_mfa_compliant: {
|
||||
Args: Record<PropertyKey, never>
|
||||
Returns: boolean
|
||||
@@ -1897,6 +1905,7 @@ export type Database = {
|
||||
| "settings.manage"
|
||||
| "members.manage"
|
||||
| "invites.manage"
|
||||
application_role: "user" | "doctor" | "super_admin"
|
||||
billing_provider: "stripe" | "lemon-squeezy" | "paddle" | "montonio"
|
||||
notification_channel: "in_app" | "email"
|
||||
notification_type: "info" | "warning" | "error"
|
||||
@@ -7791,6 +7800,7 @@ export const Constants = {
|
||||
"members.manage",
|
||||
"invites.manage",
|
||||
],
|
||||
application_role: ["user", "doctor", "super_admin"],
|
||||
billing_provider: ["stripe", "lemon-squeezy", "paddle", "montonio"],
|
||||
notification_channel: ["in_app", "email"],
|
||||
notification_type: ["info", "warning", "error"],
|
||||
|
||||
60
pnpm-lock.yaml
generated
60
pnpm-lock.yaml
generated
@@ -38,6 +38,9 @@ importers:
|
||||
'@kit/database-webhooks':
|
||||
specifier: workspace:*
|
||||
version: link:packages/database-webhooks
|
||||
'@kit/doctor':
|
||||
specifier: workspace:*
|
||||
version: link:packages/features/doctor
|
||||
'@kit/email-templates':
|
||||
specifier: workspace:*
|
||||
version: link:packages/email-templates
|
||||
@@ -783,6 +786,63 @@ importers:
|
||||
specifier: ^2.0.3
|
||||
version: 2.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
|
||||
packages/features/doctor:
|
||||
devDependencies:
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.0.1
|
||||
version: 5.1.1(react-hook-form@7.58.0(react@19.1.0))
|
||||
'@kit/eslint-config':
|
||||
specifier: workspace:*
|
||||
version: link:../../../tooling/eslint
|
||||
'@kit/next':
|
||||
specifier: workspace:*
|
||||
version: link:../../next
|
||||
'@kit/prettier-config':
|
||||
specifier: workspace:*
|
||||
version: link:../../../tooling/prettier
|
||||
'@kit/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../../shared
|
||||
'@kit/supabase':
|
||||
specifier: workspace:*
|
||||
version: link:../../supabase
|
||||
'@kit/tsconfig':
|
||||
specifier: workspace:*
|
||||
version: link:../../../tooling/typescript
|
||||
'@kit/ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../ui
|
||||
'@makerkit/data-loader-supabase-core':
|
||||
specifier: ^0.0.10
|
||||
version: 0.0.10(@supabase/postgrest-js@1.19.4)(@supabase/supabase-js@2.49.4)
|
||||
'@makerkit/data-loader-supabase-nextjs':
|
||||
specifier: ^1.2.5
|
||||
version: 1.2.5(@supabase/postgrest-js@1.19.4)(@supabase/supabase-js@2.49.4)(@tanstack/react-query@5.76.1(react@19.1.0))(next@15.3.2(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)
|
||||
'@supabase/supabase-js':
|
||||
specifier: 2.49.4
|
||||
version: 2.49.4
|
||||
'@tanstack/react-query':
|
||||
specifier: 5.76.1
|
||||
version: 5.76.1(react@19.1.0)
|
||||
'@tanstack/react-table':
|
||||
specifier: ^8.21.3
|
||||
version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@types/react':
|
||||
specifier: 19.1.4
|
||||
version: 19.1.4
|
||||
lucide-react:
|
||||
specifier: ^0.510.0
|
||||
version: 0.510.0(react@19.1.0)
|
||||
next:
|
||||
specifier: 15.3.2
|
||||
version: 15.3.2(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react:
|
||||
specifier: 19.1.0
|
||||
version: 19.1.0
|
||||
react-dom:
|
||||
specifier: 19.1.0
|
||||
version: 19.1.0(react@19.1.0)
|
||||
|
||||
packages/features/medusa-storefront:
|
||||
dependencies:
|
||||
'@headlessui/react':
|
||||
|
||||
@@ -122,5 +122,8 @@
|
||||
"consentToAnonymizedCompanyData": {
|
||||
"label": "Consent to be included in employer statistics",
|
||||
"description": "Consent to be included in anonymized company statistics"
|
||||
}
|
||||
}
|
||||
},
|
||||
"updateRoleSuccess": "Role updated",
|
||||
"updateRoleError": "Something went wrong, please try again",
|
||||
"updateRoleLoading": "Updating role..."
|
||||
}
|
||||
@@ -109,5 +109,6 @@
|
||||
"description": "This website uses cookies to ensure you get the best experience on our website.",
|
||||
"reject": "Reject",
|
||||
"accept": "Accept"
|
||||
}
|
||||
},
|
||||
"doctor": "Doctor"
|
||||
}
|
||||
@@ -145,5 +145,8 @@
|
||||
"successTitle": "Tere, {{firstName}} {{lastName}}",
|
||||
"successDescription": "Teie tervisekonto on aktiveeritud ja kasutamiseks valmis!",
|
||||
"successButton": "Jätka"
|
||||
}
|
||||
}
|
||||
},
|
||||
"updateRoleSuccess": "Roll uuendatud",
|
||||
"updateRoleError": "Midagi läks valesti. Palun proovi uuesti",
|
||||
"updateRoleLoading": "Rolli uuendatakse..."
|
||||
}
|
||||
@@ -127,5 +127,6 @@
|
||||
"wallet": {
|
||||
"balance": "Sinu MedReporti konto seis",
|
||||
"expiredAt": "Kehtiv kuni {{expiredAt}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctor": "Arst"
|
||||
}
|
||||
@@ -122,5 +122,8 @@
|
||||
},
|
||||
"analysisResults": {
|
||||
"pageTitle": "My analysis results"
|
||||
}
|
||||
}
|
||||
},
|
||||
"updateRoleSuccess": "Role updated",
|
||||
"updateRoleError": "Something went wrong, please try again",
|
||||
"updateRoleLoading": "Updating role..."
|
||||
}
|
||||
@@ -107,5 +107,6 @@
|
||||
"description": "This website uses cookies to ensure you get the best experience on our website.",
|
||||
"reject": "Reject",
|
||||
"accept": "Accept"
|
||||
}
|
||||
},
|
||||
"doctor": "Doctor"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
create type "medreport"."application_role" as enum ('user', 'doctor', 'super_admin');
|
||||
|
||||
ALTER TABLE medreport.accounts
|
||||
ADD COLUMN "application_role" medreport.application_role NOT NULL DEFAULT 'user';
|
||||
|
||||
CREATE OR REPLACE FUNCTION medreport.is_doctor()
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN medreport.is_aal2() AND (EXISTS (
|
||||
SELECT 1
|
||||
FROM medreport.accounts
|
||||
WHERE primary_owner_user_id = auth.uid()
|
||||
AND application_role = 'doctor'
|
||||
));
|
||||
END;
|
||||
$$;
|
||||
grant execute on function medreport.is_doctor() to authenticated;
|
||||
|
||||
|
||||
CREATE OR REPLACE FUNCTION medreport.is_super_admin()
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN medreport.is_aal2() AND (EXISTS (
|
||||
SELECT 1
|
||||
FROM medreport.accounts
|
||||
WHERE primary_owner_user_id = auth.uid()
|
||||
AND application_role = 'super_admin'
|
||||
));
|
||||
END;
|
||||
$$;
|
||||
grant execute on function medreport.is_super_admin() to authenticated;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- Update your user role to Super Admin
|
||||
update auth.users set raw_app_meta_data='{"provider": "email", "providers": ["email"], "role": "super-admin" }' where email='test2@test.ee';
|
||||
Reference in New Issue
Block a user