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:
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user