mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
feature: gate audit log by permissions (#3464)
* pairing * restricting audit route resolvees #3460 * updated tests * fixing lint * useSelector instead of useActor
This commit is contained in:
@ -88,6 +88,7 @@ var (
|
|||||||
// Should be able to read all template details, even in orgs they
|
// Should be able to read all template details, even in orgs they
|
||||||
// are not in.
|
// are not in.
|
||||||
ResourceTemplate: {ActionRead},
|
ResourceTemplate: {ActionRead},
|
||||||
|
ResourceAuditLog: {ActionRead},
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -22,6 +22,12 @@ var (
|
|||||||
Type: "workspace",
|
Type: "workspace",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResourceAuditLog
|
||||||
|
// read = access audit log
|
||||||
|
ResourceAuditLog = Object{
|
||||||
|
Type: "audit_log",
|
||||||
|
}
|
||||||
|
|
||||||
// ResourceTemplate CRUD. Org owner only.
|
// ResourceTemplate CRUD. Org owner only.
|
||||||
// create/delete = Make or delete a new template
|
// create/delete = Make or delete a new template
|
||||||
// update = Update the template, make new template versions
|
// update = Update the template, make new template versions
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
import { FC, lazy, Suspense } from "react"
|
import { useSelector } from "@xstate/react"
|
||||||
|
import { FC, lazy, Suspense, useContext } from "react"
|
||||||
import { Navigate, Route, Routes } from "react-router-dom"
|
import { Navigate, Route, Routes } from "react-router-dom"
|
||||||
|
import { selectPermissions } from "xServices/auth/authSelectors"
|
||||||
|
import { XServiceContext } from "xServices/StateContext"
|
||||||
import { AuthAndFrame } from "./components/AuthAndFrame/AuthAndFrame"
|
import { AuthAndFrame } from "./components/AuthAndFrame/AuthAndFrame"
|
||||||
import { RequireAuth } from "./components/RequireAuth/RequireAuth"
|
import { RequireAuth } from "./components/RequireAuth/RequireAuth"
|
||||||
import { SettingsLayout } from "./components/SettingsLayout/SettingsLayout"
|
import { SettingsLayout } from "./components/SettingsLayout/SettingsLayout"
|
||||||
@ -27,167 +30,172 @@ const WorkspacesPage = lazy(() => import("./pages/WorkspacesPage/WorkspacesPage"
|
|||||||
const CreateWorkspacePage = lazy(() => import("./pages/CreateWorkspacePage/CreateWorkspacePage"))
|
const CreateWorkspacePage = lazy(() => import("./pages/CreateWorkspacePage/CreateWorkspacePage"))
|
||||||
const AuditPage = lazy(() => import("./pages/AuditPage/AuditPage"))
|
const AuditPage = lazy(() => import("./pages/AuditPage/AuditPage"))
|
||||||
|
|
||||||
export const AppRouter: FC = () => (
|
export const AppRouter: FC = () => {
|
||||||
<Suspense fallback={<></>}>
|
const xServices = useContext(XServiceContext)
|
||||||
<Routes>
|
const permissions = useSelector(xServices.authXService, selectPermissions)
|
||||||
<Route
|
|
||||||
index
|
|
||||||
element={
|
|
||||||
<RequireAuth>
|
|
||||||
<IndexPage />
|
|
||||||
</RequireAuth>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route path="login" element={<LoginPage />} />
|
return (
|
||||||
<Route path="healthz" element={<HealthzPage />} />
|
<Suspense fallback={<></>}>
|
||||||
<Route
|
<Routes>
|
||||||
path="cli-auth"
|
|
||||||
element={
|
|
||||||
<RequireAuth>
|
|
||||||
<CliAuthenticationPage />
|
|
||||||
</RequireAuth>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route path="workspaces">
|
|
||||||
<Route
|
<Route
|
||||||
index
|
index
|
||||||
element={
|
element={
|
||||||
<AuthAndFrame>
|
<RequireAuth>
|
||||||
<WorkspacesPage />
|
<IndexPage />
|
||||||
</AuthAndFrame>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="templates">
|
<Route path="login" element={<LoginPage />} />
|
||||||
|
<Route path="healthz" element={<HealthzPage />} />
|
||||||
<Route
|
<Route
|
||||||
index
|
path="cli-auth"
|
||||||
element={
|
element={
|
||||||
<AuthAndFrame>
|
<RequireAuth>
|
||||||
<TemplatesPage />
|
<CliAuthenticationPage />
|
||||||
</AuthAndFrame>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route path=":template">
|
<Route path="workspaces">
|
||||||
<Route
|
<Route
|
||||||
index
|
index
|
||||||
element={
|
element={
|
||||||
<AuthAndFrame>
|
<AuthAndFrame>
|
||||||
<TemplatePage />
|
<WorkspacesPage />
|
||||||
|
</AuthAndFrame>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="templates">
|
||||||
|
<Route
|
||||||
|
index
|
||||||
|
element={
|
||||||
|
<AuthAndFrame>
|
||||||
|
<TemplatesPage />
|
||||||
|
</AuthAndFrame>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route path=":template">
|
||||||
|
<Route
|
||||||
|
index
|
||||||
|
element={
|
||||||
|
<AuthAndFrame>
|
||||||
|
<TemplatePage />
|
||||||
|
</AuthAndFrame>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="workspace"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<CreateWorkspacePage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="users">
|
||||||
|
<Route
|
||||||
|
index
|
||||||
|
element={
|
||||||
|
<AuthAndFrame>
|
||||||
|
<UsersPage />
|
||||||
</AuthAndFrame>
|
</AuthAndFrame>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="workspace"
|
path="create"
|
||||||
element={
|
element={
|
||||||
<RequireAuth>
|
<RequireAuth>
|
||||||
<CreateWorkspacePage />
|
<CreateUserPage />
|
||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="users">
|
{/* REMARK: Route under construction
|
||||||
<Route
|
Eventually, we should gate this page
|
||||||
index
|
with permissions and licensing */}
|
||||||
element={
|
<Route path="/audit">
|
||||||
<AuthAndFrame>
|
|
||||||
<UsersPage />
|
|
||||||
</AuthAndFrame>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="create"
|
|
||||||
element={
|
|
||||||
<RequireAuth>
|
|
||||||
<CreateUserPage />
|
|
||||||
</RequireAuth>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
{/* REMARK: Route under construction
|
|
||||||
Eventually, we should gate this page
|
|
||||||
with permissions and licensing */}
|
|
||||||
<Route path="/audit">
|
|
||||||
<Route
|
|
||||||
index
|
|
||||||
element={
|
|
||||||
process.env.NODE_ENV === "production" ? (
|
|
||||||
<Navigate to="/workspaces" />
|
|
||||||
) : (
|
|
||||||
<AuthAndFrame>
|
|
||||||
<AuditPage />
|
|
||||||
</AuthAndFrame>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
></Route>
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="settings" element={<SettingsLayout />}>
|
|
||||||
<Route path="account" element={<AccountPage />} />
|
|
||||||
<Route path="security" element={<SecurityPage />} />
|
|
||||||
<Route path="ssh-keys" element={<SSHKeysPage />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="/@:username">
|
|
||||||
<Route path=":workspace">
|
|
||||||
<Route
|
<Route
|
||||||
index
|
index
|
||||||
element={
|
element={
|
||||||
<AuthAndFrame>
|
process.env.NODE_ENV === "production" || !permissions?.viewAuditLog ? (
|
||||||
<WorkspacePage />
|
<Navigate to="/workspaces" />
|
||||||
</AuthAndFrame>
|
) : (
|
||||||
|
<AuthAndFrame>
|
||||||
|
<AuditPage />
|
||||||
|
</AuthAndFrame>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
></Route>
|
||||||
<Route
|
</Route>
|
||||||
path="schedule"
|
|
||||||
element={
|
|
||||||
<RequireAuth>
|
|
||||||
<WorkspaceSchedulePage />
|
|
||||||
</RequireAuth>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route
|
<Route path="settings" element={<SettingsLayout />}>
|
||||||
path="terminal"
|
<Route path="account" element={<AccountPage />} />
|
||||||
element={
|
<Route path="security" element={<SecurityPage />} />
|
||||||
<RequireAuth>
|
<Route path="ssh-keys" element={<SSHKeysPage />} />
|
||||||
<TerminalPage />
|
</Route>
|
||||||
</RequireAuth>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route path="apps">
|
<Route path="/@:username">
|
||||||
|
<Route path=":workspace">
|
||||||
<Route
|
<Route
|
||||||
path=":app/*"
|
index
|
||||||
element={
|
element={
|
||||||
<AuthAndFrame>
|
<AuthAndFrame>
|
||||||
<WorkspaceAppErrorPage />
|
<WorkspacePage />
|
||||||
|
</AuthAndFrame>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="schedule"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<WorkspaceSchedulePage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="terminal"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<TerminalPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route path="apps">
|
||||||
|
<Route
|
||||||
|
path=":app/*"
|
||||||
|
element={
|
||||||
|
<AuthAndFrame>
|
||||||
|
<WorkspaceAppErrorPage />
|
||||||
|
</AuthAndFrame>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="builds/:buildNumber"
|
||||||
|
element={
|
||||||
|
<AuthAndFrame>
|
||||||
|
<WorkspaceBuildPage />
|
||||||
</AuthAndFrame>
|
</AuthAndFrame>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route
|
|
||||||
path="builds/:buildNumber"
|
|
||||||
element={
|
|
||||||
<AuthAndFrame>
|
|
||||||
<WorkspaceBuildPage />
|
|
||||||
</AuthAndFrame>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
|
||||||
|
|
||||||
{/* Using path="*"" means "match anything", so this route
|
{/* Using path="*"" means "match anything", so this route
|
||||||
acts like a catch-all for URLs that we don't have explicit
|
acts like a catch-all for URLs that we don't have explicit
|
||||||
routes for. */}
|
routes for. */}
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
@ -6,8 +6,14 @@ import { NavbarView } from "../NavbarView/NavbarView"
|
|||||||
export const Navbar: React.FC = () => {
|
export const Navbar: React.FC = () => {
|
||||||
const xServices = useContext(XServiceContext)
|
const xServices = useContext(XServiceContext)
|
||||||
const [authState, authSend] = useActor(xServices.authXService)
|
const [authState, authSend] = useActor(xServices.authXService)
|
||||||
const { me } = authState.context
|
const { me, permissions } = authState.context
|
||||||
const onSignOut = () => authSend("SIGN_OUT")
|
const onSignOut = () => authSend("SIGN_OUT")
|
||||||
|
|
||||||
return <NavbarView user={me} onSignOut={onSignOut} />
|
return (
|
||||||
|
<NavbarView
|
||||||
|
user={me}
|
||||||
|
onSignOut={onSignOut}
|
||||||
|
canViewAuditLog={permissions?.viewAuditLog ?? false}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { screen } from "@testing-library/react"
|
import { screen } from "@testing-library/react"
|
||||||
import { MockUser } from "../../testHelpers/entities"
|
import { MockUser, MockUser2 } from "../../testHelpers/entities"
|
||||||
import { render } from "../../testHelpers/renderHelpers"
|
import { render } from "../../testHelpers/renderHelpers"
|
||||||
import { Language as navLanguage, NavbarView } from "./NavbarView"
|
import { Language as navLanguage, NavbarView } from "./NavbarView"
|
||||||
|
|
||||||
@ -22,26 +22,26 @@ describe("NavbarView", () => {
|
|||||||
|
|
||||||
it("renders content", async () => {
|
it("renders content", async () => {
|
||||||
// When
|
// When
|
||||||
render(<NavbarView user={MockUser} onSignOut={noop} />)
|
render(<NavbarView user={MockUser} onSignOut={noop} canViewAuditLog />)
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
await screen.findAllByText("Coder", { exact: false })
|
await screen.findAllByText("Coder", { exact: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
it("workspaces nav link has the correct href", async () => {
|
it("workspaces nav link has the correct href", async () => {
|
||||||
render(<NavbarView user={MockUser} onSignOut={noop} />)
|
render(<NavbarView user={MockUser} onSignOut={noop} canViewAuditLog />)
|
||||||
const workspacesLink = await screen.findByText(navLanguage.workspaces)
|
const workspacesLink = await screen.findByText(navLanguage.workspaces)
|
||||||
expect((workspacesLink as HTMLAnchorElement).href).toContain("/workspaces")
|
expect((workspacesLink as HTMLAnchorElement).href).toContain("/workspaces")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("templates nav link has the correct href", async () => {
|
it("templates nav link has the correct href", async () => {
|
||||||
render(<NavbarView user={MockUser} onSignOut={noop} />)
|
render(<NavbarView user={MockUser} onSignOut={noop} canViewAuditLog />)
|
||||||
const templatesLink = await screen.findByText(navLanguage.templates)
|
const templatesLink = await screen.findByText(navLanguage.templates)
|
||||||
expect((templatesLink as HTMLAnchorElement).href).toContain("/templates")
|
expect((templatesLink as HTMLAnchorElement).href).toContain("/templates")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("users nav link has the correct href", async () => {
|
it("users nav link has the correct href", async () => {
|
||||||
render(<NavbarView user={MockUser} onSignOut={noop} />)
|
render(<NavbarView user={MockUser} onSignOut={noop} canViewAuditLog />)
|
||||||
const userLink = await screen.findByText(navLanguage.users)
|
const userLink = await screen.findByText(navLanguage.users)
|
||||||
expect((userLink as HTMLAnchorElement).href).toContain("/users")
|
expect((userLink as HTMLAnchorElement).href).toContain("/users")
|
||||||
})
|
})
|
||||||
@ -54,7 +54,7 @@ describe("NavbarView", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// When
|
// When
|
||||||
render(<NavbarView user={mockUser} onSignOut={noop} />)
|
render(<NavbarView user={mockUser} onSignOut={noop} canViewAuditLog />)
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
// There should be a 'B' avatar!
|
// There should be a 'B' avatar!
|
||||||
@ -63,7 +63,7 @@ describe("NavbarView", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("audit nav link has the correct href", async () => {
|
it("audit nav link has the correct href", async () => {
|
||||||
render(<NavbarView user={MockUser} onSignOut={noop} />)
|
render(<NavbarView user={MockUser} onSignOut={noop} canViewAuditLog />)
|
||||||
const auditLink = await screen.findByText(navLanguage.audit)
|
const auditLink = await screen.findByText(navLanguage.audit)
|
||||||
expect((auditLink as HTMLAnchorElement).href).toContain("/audit")
|
expect((auditLink as HTMLAnchorElement).href).toContain("/audit")
|
||||||
})
|
})
|
||||||
@ -74,7 +74,13 @@ describe("NavbarView", () => {
|
|||||||
NODE_ENV: "production",
|
NODE_ENV: "production",
|
||||||
}
|
}
|
||||||
|
|
||||||
render(<NavbarView user={MockUser} onSignOut={noop} />)
|
render(<NavbarView user={MockUser} onSignOut={noop} canViewAuditLog />)
|
||||||
|
const auditLink = screen.queryByText(navLanguage.audit)
|
||||||
|
expect(auditLink).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("audit nav link is hidden for members", async () => {
|
||||||
|
render(<NavbarView user={MockUser2} onSignOut={noop} canViewAuditLog={false} />)
|
||||||
const auditLink = screen.queryByText(navLanguage.audit)
|
const auditLink = screen.queryByText(navLanguage.audit)
|
||||||
expect(auditLink).not.toBeInTheDocument()
|
expect(auditLink).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
@ -15,6 +15,7 @@ import { UserDropdown } from "../UserDropdown/UsersDropdown"
|
|||||||
export interface NavbarViewProps {
|
export interface NavbarViewProps {
|
||||||
user?: TypesGen.User
|
user?: TypesGen.User
|
||||||
onSignOut: () => void
|
onSignOut: () => void
|
||||||
|
canViewAuditLog: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Language = {
|
export const Language = {
|
||||||
@ -24,7 +25,10 @@ export const Language = {
|
|||||||
audit: "Audit",
|
audit: "Audit",
|
||||||
}
|
}
|
||||||
|
|
||||||
const NavItems: React.FC<{ className?: string; linkClassName?: string }> = ({ className }) => {
|
const NavItems: React.FC<{ className?: string; canViewAuditLog: boolean }> = ({
|
||||||
|
className,
|
||||||
|
canViewAuditLog,
|
||||||
|
}) => {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
@ -49,7 +53,7 @@ const NavItems: React.FC<{ className?: string; linkClassName?: string }> = ({ cl
|
|||||||
</NavLink>
|
</NavLink>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
{/* REMARK: the below link is under-construction */}
|
{/* REMARK: the below link is under-construction */}
|
||||||
{process.env.NODE_ENV !== "production" && (
|
{process.env.NODE_ENV !== "production" && canViewAuditLog && (
|
||||||
<ListItem button className={styles.item}>
|
<ListItem button className={styles.item}>
|
||||||
<NavLink className={styles.link} to="/audit">
|
<NavLink className={styles.link} to="/audit">
|
||||||
{Language.audit}
|
{Language.audit}
|
||||||
@ -60,7 +64,7 @@ const NavItems: React.FC<{ className?: string; linkClassName?: string }> = ({ cl
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NavbarView: React.FC<NavbarViewProps> = ({ user, onSignOut }) => {
|
export const NavbarView: React.FC<NavbarViewProps> = ({ user, onSignOut, canViewAuditLog }) => {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
|
||||||
|
|
||||||
@ -81,7 +85,7 @@ export const NavbarView: React.FC<NavbarViewProps> = ({ user, onSignOut }) => {
|
|||||||
<div className={styles.drawerHeader}>
|
<div className={styles.drawerHeader}>
|
||||||
<Logo fill="white" opacity={1} width={125} />
|
<Logo fill="white" opacity={1} width={125} />
|
||||||
</div>
|
</div>
|
||||||
<NavItems />
|
<NavItems canViewAuditLog={canViewAuditLog} />
|
||||||
</div>
|
</div>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
@ -89,7 +93,7 @@ export const NavbarView: React.FC<NavbarViewProps> = ({ user, onSignOut }) => {
|
|||||||
<Logo fill="white" opacity={1} width={125} />
|
<Logo fill="white" opacity={1} width={125} />
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
<NavItems className={styles.desktopNavItems} />
|
<NavItems className={styles.desktopNavItems} canViewAuditLog={canViewAuditLog} />
|
||||||
|
|
||||||
<div className={styles.profileButton}>
|
<div className={styles.profileButton}>
|
||||||
{user && <UserDropdown user={user} onSignOut={onSignOut} />}
|
{user && <UserDropdown user={user} onSignOut={onSignOut} />}
|
||||||
|
@ -14,6 +14,7 @@ export const checks = {
|
|||||||
updateUsers: "updateUsers",
|
updateUsers: "updateUsers",
|
||||||
createUser: "createUser",
|
createUser: "createUser",
|
||||||
createTemplates: "createTemplates",
|
createTemplates: "createTemplates",
|
||||||
|
viewAuditLog: "viewAuditLog",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const permissionsToCheck = {
|
export const permissionsToCheck = {
|
||||||
@ -41,6 +42,12 @@ export const permissionsToCheck = {
|
|||||||
},
|
},
|
||||||
action: "write",
|
action: "write",
|
||||||
},
|
},
|
||||||
|
[checks.viewAuditLog]: {
|
||||||
|
object: {
|
||||||
|
resource_type: "audit_log",
|
||||||
|
},
|
||||||
|
action: "read",
|
||||||
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
type Permissions = Record<keyof typeof permissionsToCheck, boolean>
|
type Permissions = Record<keyof typeof permissionsToCheck, boolean>
|
||||||
|
Reference in New Issue
Block a user