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:
Helena
2025-08-13 12:28:50 +03:00
committed by GitHub
parent ce7b04fda8
commit 3c6c86c7c8
32 changed files with 562 additions and 35 deletions

View File

@@ -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}>

View File

@@ -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,
),
}));
}
}

View File

@@ -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: '',

View File

@@ -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');
}

View File

@@ -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>,
});

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,3 @@
import eslintConfigBase from '@kit/eslint-config/base.js';
export default eslintConfigBase;

View 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/*"
]
}
}
}

View 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} />;
};
}

View File

@@ -0,0 +1 @@
export * from './lib/server/utils/is-doctor';

View 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;
}
}

View File

@@ -0,0 +1,10 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": [
"node_modules"
]
}

View File

@@ -228,7 +228,6 @@ export function NotificationsPopover(params: {
size="icon"
variant="ghost"
onClick={() => {
console.log('test');
setNotifications((existing) => {
return existing.filter(
(existingNotification) =>

View File

@@ -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"],